diff --git a/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx b/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx index 29c31dbd99275f2afc8c53df334af36ec1f78098..eb6788801c35d82b27302f4e64664f894a7ce076 100644 --- a/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx +++ b/frontend/src/metabase/admin/databases/components/CreatedDatabaseModal.jsx @@ -3,6 +3,8 @@ import { Link } from "react-router"; import ModalContent from "metabase/components/ModalContent.jsx"; +import * as Urls from "metabase/lib/urls"; + export default class CreatedDatabaseModal extends Component { static propTypes = { databaseId: PropTypes.number.isRequired, @@ -22,7 +24,7 @@ export default class CreatedDatabaseModal extends Component { We're analyzing its schema now to make some educated guesses about its metadata. <Link to={"/admin/datamodel/database/"+databaseId}>View this database</Link> in the Data Model section to see what we've found and to - make edits, or <Link to={"/q#?db="+databaseId}>ask a question</Link> about + make edits, or <Link to={Urls.question(null, `?db=${databaseId}`)}>ask a question</Link> about this database. </p> </div> diff --git a/frontend/src/metabase/admin/datamodel/components/PartialQueryBuilder.jsx b/frontend/src/metabase/admin/datamodel/components/PartialQueryBuilder.jsx index 133fc5c27f05bcdf1b76aa478e0e8056e9cb8067..ea5abefc8c5f745bece440f429f9836f0d7dfd12 100644 --- a/frontend/src/metabase/admin/datamodel/components/PartialQueryBuilder.jsx +++ b/frontend/src/metabase/admin/datamodel/components/PartialQueryBuilder.jsx @@ -2,7 +2,7 @@ import React, { Component, PropTypes } from "react"; import GuiQueryEditor from "metabase/query_builder/components/GuiQueryEditor.jsx"; -import { serializeCardForUrl } from "metabase/lib/card"; +import * as Urls from "metabase/lib/urls"; import cx from "classnames"; @@ -56,7 +56,7 @@ export default class PartialQueryBuilder extends Component { } } }; - let previewUrl = "/q#" + serializeCardForUrl(previewCard); + let previewUrl = Urls.question(null, previewCard); const onChange = (query) => { this.props.onChange(query); diff --git a/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx b/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx index 109b043fa9f1bfe3e9a8295e6c5472340a6a0366..c2719ee5c9d4c6434c437efc1c6ff541036bc904 100644 --- a/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx +++ b/frontend/src/metabase/admin/settings/components/widgets/PublicLinksListing.jsx @@ -9,7 +9,7 @@ import Confirm from "metabase/components/Confirm"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; import { CardApi, DashboardApi } from "metabase/services"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import MetabaseAnalytics from "metabase/lib/analytics"; @@ -161,7 +161,7 @@ export const PublicLinksQuestionListing = () => load={CardApi.listPublic} revoke={CardApi.deletePublicLink} type='Public Card Listing' - getUrl={({ id }) => Urls.card(id)} + getUrl={({ id }) => Urls.question(id)} getPublicUrl={({ public_uuid }) => window.location.origin + Urls.publicCard(public_uuid)} noLinksMessage="No questions have been publicly shared yet." />; @@ -177,7 +177,7 @@ export const EmbeddedDashboardListing = () => export const EmbeddedQuestionListing = () => <PublicLinksListing load={CardApi.listEmbeddable} - getUrl={({ id }) => Urls.card(id)} + getUrl={({ id }) => Urls.question(id)} type='Embedded Card Listing' noLinksMessage="No questions have been embedded yet." />; diff --git a/frontend/src/metabase/components/AddToDashSelectDashModal.jsx b/frontend/src/metabase/components/AddToDashSelectDashModal.jsx index 408cb5777523ee39bdb95e3a7bce27f553055a8e..0492804ed33e38179f64bd9d66b10f5a31fceb19 100644 --- a/frontend/src/metabase/components/AddToDashSelectDashModal.jsx +++ b/frontend/src/metabase/components/AddToDashSelectDashModal.jsx @@ -5,7 +5,7 @@ import Icon from 'metabase/components/Icon.jsx'; import ModalContent from "metabase/components/ModalContent.jsx"; import SortableItemList from 'metabase/components/SortableItemList.jsx'; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import { DashboardApi } from "metabase/services"; import moment from 'moment'; diff --git a/frontend/src/metabase/components/NotFound.jsx b/frontend/src/metabase/components/NotFound.jsx index 0eb16a19945a2dbe89ac36d3a778b913b0820d96..cdf98c4afcac3c1fccdfc438d34ca0e947f6344b 100644 --- a/frontend/src/metabase/components/NotFound.jsx +++ b/frontend/src/metabase/components/NotFound.jsx @@ -1,6 +1,8 @@ import React, { Component, PropTypes } from "react"; import { Link } from "react-router"; +import * as Urls from "metabase/lib/urls"; + export default class NotFound extends Component { render() { return ( @@ -11,7 +13,7 @@ export default class NotFound extends Component { <p className="h4">You might've been tricked by a ninja, but in all likelihood, you were just given a bad link.</p> <p className="h4 my4">You can always:</p> <div className="flex align-center"> - <Link to="/q" className="Button Button--primary"> + <Link to={Urls.question()} className="Button Button--primary"> <div className="p1">Ask a new question.</div> </Link> <span className="mx2">or</span> diff --git a/frontend/src/metabase/dashboard/containers/DashboardEmbedWidget.jsx b/frontend/src/metabase/dashboard/containers/DashboardEmbedWidget.jsx index 6ff0deeb50520db60ecf107be57925c318123142..50ca40dee81a890a314713e9f0adac57686d7c9a 100644 --- a/frontend/src/metabase/dashboard/containers/DashboardEmbedWidget.jsx +++ b/frontend/src/metabase/dashboard/containers/DashboardEmbedWidget.jsx @@ -5,7 +5,7 @@ import { connect } from "react-redux"; import EmbedWidget from "metabase/public/components/widgets/EmbedWidget"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import { createPublicLink, deletePublicLink, updateEnableEmbedding, updateEmbeddingParams } from "../dashboard"; diff --git a/frontend/src/metabase/home/components/Activity.jsx b/frontend/src/metabase/home/components/Activity.jsx index 69dcce269099d519495e83ac6a2ff91787aca92f..bc8d65ca2c60aab05fb1102ef878b4661f4c9731 100644 --- a/frontend/src/metabase/home/components/Activity.jsx +++ b/frontend/src/metabase/home/components/Activity.jsx @@ -6,7 +6,7 @@ import LoadingAndErrorWrapper from 'metabase/components/LoadingAndErrorWrapper.j import ActivityItem from './ActivityItem.jsx'; import ActivityStory from './ActivityStory.jsx'; -import Urls from 'metabase/lib/urls'; +import * as Urls from "metabase/lib/urls"; export default class Activity extends Component { @@ -213,7 +213,7 @@ export default class Activity extends Component { case "dashboard-remove-cards": description.body = item.details.dashcards[0].name; if (item.details.dashcards[0].exists) { - description.bodyLink = Urls.card(item.details.dashcards[0].card_id); + description.bodyLink = Urls.question(item.details.dashcards[0].card_id); } break; case "metric-create": diff --git a/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx b/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx index 1de3e68b33880d05c0de16500768e7c36579329b..d58b1ff6912110f687a4df04804bae7e4fc7f8a5 100644 --- a/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx +++ b/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx @@ -2,6 +2,7 @@ import React, { Component, PropTypes } from "react"; import { Link } from "react-router"; import MetabaseSettings from "metabase/lib/settings"; +import * as Urls from "metabase/lib/urls"; export default class NewUserOnboardingModal extends Component { constructor(props, context) { @@ -84,7 +85,7 @@ export default class NewUserOnboardingModal extends Component { {this.renderStep()} <span className="flex-align-right"> <a className="text-underline-hover cursor-pointer mr3" onClick={() => (this.closeModal())}>skip for now</a> - <Link to="/q#?tutorial" className="Button Button--primary">Let's do it!</Link> + <Link to={Urls.question(null, "?tutorial")} className="Button Button--primary">Let's do it!</Link> </span> </div> </div> diff --git a/frontend/src/metabase/home/components/RecentViews.jsx b/frontend/src/metabase/home/components/RecentViews.jsx index f1146966a807b999db69613662d91df7f7d40d4f..9a967584956eed30780e4128ede71e705e7f7b1e 100644 --- a/frontend/src/metabase/home/components/RecentViews.jsx +++ b/frontend/src/metabase/home/components/RecentViews.jsx @@ -3,7 +3,7 @@ import { Link } from "react-router"; import Icon from "metabase/components/Icon.jsx"; import SidebarSection from "./SidebarSection.jsx"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import { normal } from "metabase/lib/colors"; diff --git a/frontend/src/metabase/lib/card.js b/frontend/src/metabase/lib/card.js index b53d09d1732ae751d2dcddc8f34f73d8986ac573..2b0ac9090b755dc86b2a981f6fdd1cb00de4faa3 100644 --- a/frontend/src/metabase/lib/card.js +++ b/frontend/src/metabase/lib/card.js @@ -1,6 +1,7 @@ import _ from "underscore"; import Query, { createQuery } from "metabase/lib/query"; import Utils from "metabase/lib/utils"; +import * as Urls from "metabase/lib/urls"; import { CardApi } from "metabase/services"; @@ -24,10 +25,10 @@ export function startNewCard(type, databaseId, tableId) { } // load a card either by ID or from a base64 serialization. if both are present then they are merged, which the serialized version taking precedence +// TODO: move to redux export async function loadCard(cardId) { try { - let card = await CardApi.get({ "cardId": cardId }); - return card && cleanCopyCard(card); + return await CardApi.get({ "cardId": cardId }); } catch (error) { console.log("error loading card", error); throw error; @@ -111,16 +112,10 @@ export function b64url_to_utf8(b64url) { } export function urlForCardState(state, dirty) { - var url; - if (state.cardId) { - url = "/card/" + state.cardId; - } else { - url = "/q"; - } - if (state.serializedCard && dirty) { - url += "#" + state.serializedCard; - } - return url; + return Urls.question( + state.cardId, + (state.serializedCard && dirty) ? state.serializedCard : "" + ); } export function cleanCopyCard(card) { diff --git a/frontend/src/metabase/lib/urls.js b/frontend/src/metabase/lib/urls.js index ed11450f3b0fd4c68e1de077a1e29dc25ee491ea..7c2eba73166aeef3535ac9f4ee9e9f3b995fba8d 100644 --- a/frontend/src/metabase/lib/urls.js +++ b/frontend/src/metabase/lib/urls.js @@ -1,66 +1,71 @@ import { serializeCardForUrl } from "metabase/lib/card"; // provides functions for building urls to things we care about -var Urls = { - q: function(card) { - return "/q#" + serializeCardForUrl(card); - }, - card: function(card_id) { - // NOTE that this is for an ephemeral card link, not an editable card - return "/card/"+card_id; - }, - - dashboard: function(dashboard_id) { - return "/dash/"+dashboard_id; - }, +export function question(cardId, cardOrHash = "") { + if (cardOrHash && typeof cardOrHash === "object") { + cardOrHash = serializeCardForUrl(cardOrHash); + } + if (cardOrHash && cardOrHash.charAt(0) !== "#") { + cardOrHash = "#" + cardOrHash; + } + // NOTE that this is for an ephemeral card link, not an editable card + return cardId != null + ? `/question/${cardId}${cardOrHash}` + : `/question${cardOrHash}`; +} - modelToUrl: function(model, model_id) { - switch (model) { - case "card": return Urls.card(model_id); - case "dashboard": return Urls.dashboard(model_id); - case "pulse": return Urls.pulse(model_id); - default: return null; - } - }, +export function dashboard(dashboardId) { + return `/dashboard/${dashboardId}`; +} - pulse: function(pulse_id) { - return "/pulse/#"+pulse_id; - }, +export function modelToUrl(model, modelId) { + switch (model) { + case "card": + return question(modelId); + case "dashboard": + return dashboard(modelId); + case "pulse": + return pulse(modelId); + default: + return null; + } +} - tableRowsQuery: function(database_id, table_id, metric_id, segment_id) { - let url = "/q#?db="+database_id+"&table="+table_id; +export function pulse(pulseId) { + return `/pulse/#${pulseId}`; +} - if (metric_id) { - url = url + "&metric="+metric_id; - } +export function tableRowsQuery(databaseId, tableId, metricId, segmentId) { + let query = `?db=${databaseId}&table=${tableId}`; - if (segment_id) { - url = url + "&segment="+segment_id; - } + if (metricId) { + query += `&metric=${metricId}`; + } - return url; - }, + if (segmentId) { + query += `&segment=${segmentId}`; + } - collection(collection) { - return `/questions/collections/${encodeURIComponent(collection.slug)}`; - }, + return question(null, query); +} - label(label) { - return `/questions/search?label=${encodeURIComponent(label.slug)}`; - }, +export function collection(collection) { + return `/questions/collections/${encodeURIComponent(collection.slug)}`; +} - publicCard(uuid, type = null) { - return `/public/question/${uuid}` + (type ? `.${type}` : ``); - }, +export function label(label) { + return `/questions/search?label=${encodeURIComponent(label.slug)}`; +} - publicDashboard(uuid) { - return `/public/dashboard/${uuid}`; - }, +export function publicCard(uuid, type = null) { + return `/public/question/${uuid}` + (type ? `.${type}` : ``); +} - embedCard(token, type = null) { - return `/embed/question/${token}` + (type ? `.${type}` : ``); - }, +export function publicDashboard(uuid) { + return `/public/dashboard/${uuid}`; } -export default Urls; +export function embedCard(token, type = null) { + return `/embed/question/${token}` + (type ? `.${type}` : ``); +} diff --git a/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx b/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx index b902a25096e2c25ff4cdac9d6854c6b69643a88c..425c4d220862d0d318126d3c532ed7560f8ca26f 100644 --- a/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx +++ b/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx @@ -3,12 +3,13 @@ import { connect } from "react-redux"; import { Link } from "react-router"; import OnClickOutsideWrapper from 'metabase/components/OnClickOutsideWrapper.jsx'; - -import MetabaseAnalytics from "metabase/lib/analytics"; import CreateDashboardModal from "metabase/components/CreateDashboardModal.jsx"; import Modal from "metabase/components/Modal.jsx"; import ConstrainToScreen from "metabase/components/ConstrainToScreen"; +import MetabaseAnalytics from "metabase/lib/analytics"; +import * as Urls from "metabase/lib/urls"; + import _ from "underscore"; import cx from "classnames"; @@ -62,7 +63,7 @@ export default class DashboardsDropdown extends Component { try { let action = await createDashboard(newDashboard, true); // FIXME: this doesn't feel right... - this.props.onChangeLocation(`/dash/${action.payload.id}`); + this.props.onChangeLocation(Urls.dashboard(action.payload.id)); } catch (e) { console.log("createDashboard failed", e); } @@ -137,7 +138,7 @@ export default class DashboardsDropdown extends Component { <ul className="NavDropdown-content-layer"> { dashboards.map(dash => <li key={dash.id} className="block"> - <Link to={"/dash/"+dash.id} data-metabase-event={"Navbar;Dashboard Dropdown;Open Dashboard;"+dash.id} className="Dropdown-item block text-white no-decoration" onClick={this.closeDropdown}> + <Link to={Urls.dashboard(dash.id)} data-metabase-event={"Navbar;Dashboard Dropdown;Open Dashboard;"+dash.id} className="Dropdown-item block text-white no-decoration" onClick={this.closeDropdown}> <div className="flex text-bold"> {dash.name} </div> diff --git a/frontend/src/metabase/nav/containers/Navbar.jsx b/frontend/src/metabase/nav/containers/Navbar.jsx index 74520d03ff4d49a44a9bc1a91e2df4f3bf0ee24c..3dc5c7f4cef6f4ebf7bba46752d1e5fdf9baa5bb 100644 --- a/frontend/src/metabase/nav/containers/Navbar.jsx +++ b/frontend/src/metabase/nav/containers/Navbar.jsx @@ -11,6 +11,8 @@ import LogoIcon from "metabase/components/LogoIcon.jsx"; import DashboardsDropdown from "metabase/nav/containers/DashboardsDropdown.jsx"; import ProfileLink from "metabase/nav/components/ProfileLink.jsx"; +import * as Urls from "metabase/lib/urls"; + import { getPath, getContext, getUser } from "../selectors"; const mapStateToProps = (state, props) => ({ @@ -115,7 +117,13 @@ export default class Navbar extends Component { </li> <li className="pl3"> <DashboardsDropdown {...this.props}> - <a data-metabase-event={"Navbar;Dashboard Dropdown;Toggle"} style={this.styles.navButton} className={cx("NavDropdown-button NavItem text-white text-bold cursor-pointer px2 flex align-center transition-background", {"NavItem--selected": this.isActive("/dash/")})}> + <a + data-metabase-event={"Navbar;Dashboard Dropdown;Toggle"} + style={this.styles.navButton} + className={cx("NavDropdown-button NavItem text-white text-bold cursor-pointer px2 flex align-center transition-background", { + "NavItem--selected": this.isActive("/dashboard/") + })} + > <span className="NavDropdown-button-layer"> Dashboards <Icon className="ml1" name={'chevrondown'} size={8}></Icon> @@ -133,7 +141,7 @@ export default class Navbar extends Component { <Link to="/reference/guide" data-metabase-event={"Navbar;DataReference"} style={this.styles.navButton} className={cx("NavItem cursor-pointer text-white text-bold no-decoration flex align-center px2 transition-background")} activeClassName="NavItem--selected">Data Reference</Link> </li> <li className="pl3"> - <Link to="/q" data-metabase-event={"Navbar;New Question"} style={this.styles.newQuestion} className="NavNewQuestion rounded inline-block bg-white text-brand text-bold cursor-pointer px2 no-decoration transition-all">New <span className="hide sm-show">Question</span></Link> + <Link to={Urls.question()} data-metabase-event={"Navbar;New Question"} style={this.styles.newQuestion} className="NavNewQuestion rounded inline-block bg-white text-brand text-bold cursor-pointer px2 no-decoration transition-all">New <span className="hide sm-show">Question</span></Link> </li> <li className="flex-align-right transition-background"> <div className="inline-block text-white"><ProfileLink {...this.props}></ProfileLink></div> diff --git a/frontend/src/metabase/pulse/components/PulseListItem.jsx b/frontend/src/metabase/pulse/components/PulseListItem.jsx index 7429928e757872a2c685fb3d3549fe073191833a..47c0e3b7e2c6468f17cdcef9b4fda291c0572723 100644 --- a/frontend/src/metabase/pulse/components/PulseListItem.jsx +++ b/frontend/src/metabase/pulse/components/PulseListItem.jsx @@ -5,7 +5,7 @@ import { Link } from "react-router"; import cx from "classnames"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import PulseListChannel from "./PulseListChannel.jsx"; export default class PulseListItem extends Component { @@ -43,7 +43,7 @@ export default class PulseListItem extends Component { <ol className="mb2 px4 flex flex-wrap"> { pulse.cards.map((card, index) => <li key={index} className="mr1 mb1"> - <Link to={Urls.card(card.id)} className="Button"> + <Link to={Urls.question(card.id)} className="Button"> {card.name} </Link> </li> diff --git a/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx b/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx index 58ead4423d3d5bfc1e7931c0bb4b6f11bb1d2664..337b5ac6b1f0c91c5523244ba746833e0b63483e 100644 --- a/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx +++ b/frontend/src/metabase/query_builder/components/QueryDownloadWidget.jsx @@ -7,7 +7,7 @@ import Tooltip from "metabase/components/Tooltip.jsx"; import FieldSet from "metabase/components/FieldSet.jsx"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import _ from "underscore"; import cx from "classnames"; diff --git a/frontend/src/metabase/query_builder/components/QueryHeader.jsx b/frontend/src/metabase/query_builder/components/QueryHeader.jsx index 2e61da66556b74a6600bced942a4b3457a78ee0d..ff451023ecaa2485dfb2044d9a0cb93533fdc446 100644 --- a/frontend/src/metabase/query_builder/components/QueryHeader.jsx +++ b/frontend/src/metabase/query_builder/components/QueryHeader.jsx @@ -23,7 +23,7 @@ import { CardApi, RevisionApi } from "metabase/services"; import MetabaseAnalytics from "metabase/lib/analytics"; import Query from "metabase/lib/query"; import { cancelable } from "metabase/lib/promise"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import cx from "classnames"; import _ from "underscore"; diff --git a/frontend/src/metabase/query_builder/components/QueryVisualization.jsx b/frontend/src/metabase/query_builder/components/QueryVisualization.jsx index c236ebd05c6a7eca95310cfbf2ae8f178ae4a85a..b83c10c334110d563ed19da32b8065fe63ad9520 100644 --- a/frontend/src/metabase/query_builder/components/QueryVisualization.jsx +++ b/frontend/src/metabase/query_builder/components/QueryVisualization.jsx @@ -15,6 +15,7 @@ import QuestionEmbedWidget from "../containers/QuestionEmbedWidget"; import { formatNumber, inflect } from "metabase/lib/formatting"; import Utils from "metabase/lib/utils"; import MetabaseSettings from "metabase/lib/settings"; +import * as Urls from "metabase/lib/urls"; import cx from "classnames"; import _ from "underscore"; @@ -188,5 +189,5 @@ export default class QueryVisualization extends Component { const VisualizationEmptyState = ({showTutorialLink}) => <div className="flex full layout-centered text-grey-1 flex-column"> <h1>If you give me some data I can show you something cool. Run a Query!</h1> - { showTutorialLink && <Link to="/q#?tutorial" className="link cursor-pointer my2">How do I use this thing?</Link> } + { showTutorialLink && <Link to={Urls.question(null, "?tutorial")} className="link cursor-pointer my2">How do I use this thing?</Link> } </div> diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx index 95f367df9fa450f25cb94ddc88ed670c35a0138b..e0fddf9f11224e94198af92083e27dd7dae97aee 100644 --- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx +++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx @@ -154,9 +154,9 @@ export default class QueryBuilder extends Component { if (nextProps.location.action === "POP" && getURL(nextProps.location) !== getURL(this.props.location)) { this.props.popState(nextProps.location); - } else if (this.props.location.query.tutorial === undefined && nextProps.location.query.tutorial !== undefined) { + } else if (this.props.location.hash !== "#?tutorial" && nextProps.location.hash === "#?tutorial") { this.props.initializeQB(nextProps.location, nextProps.params); - } else if (getURL(nextProps.location) === "/q" && getURL(this.props.location) !== "/q") { + } else if (getURL(nextProps.location) === "/question" && getURL(this.props.location) !== "/question") { this.props.initializeQB(nextProps.location, nextProps.params); } } diff --git a/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx b/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx index d285d4c94d8dc3c1d6b1402e76bbc91ca5a3f36c..faf973c0dcde01a03a5344df4eacb9b05aad9e71 100644 --- a/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx +++ b/frontend/src/metabase/query_builder/containers/QuestionEmbedWidget.jsx @@ -5,7 +5,7 @@ import { connect } from "react-redux"; import EmbedWidget from "metabase/public/components/widgets/EmbedWidget"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import { getParameters } from "metabase/meta/Card"; import { createPublicLink, deletePublicLink, updateEnableEmbedding, updateEmbeddingParams, } from "../actions"; diff --git a/frontend/src/metabase/questions/collections.js b/frontend/src/metabase/questions/collections.js index 98ffd30aa0967674657997ae18af6a66f19d9069..592417105a325b7ba5ad1890a68ff19bc568216d 100644 --- a/frontend/src/metabase/questions/collections.js +++ b/frontend/src/metabase/questions/collections.js @@ -2,7 +2,7 @@ import { createAction, createThunkAction, handleActions, combineReducers } from "metabase/lib/redux"; import { reset } from 'redux-form'; import { replace } from "react-router-redux"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import _ from "underscore"; diff --git a/frontend/src/metabase/questions/components/CollectionBadge.jsx b/frontend/src/metabase/questions/components/CollectionBadge.jsx index 143520b64333f5aa63cb28db9bc50eef5d7dfa91..17cb724c593c6a9675ae80f29e048a7d60b49483 100644 --- a/frontend/src/metabase/questions/components/CollectionBadge.jsx +++ b/frontend/src/metabase/questions/components/CollectionBadge.jsx @@ -1,7 +1,7 @@ import React, { Component, PropTypes } from "react"; import { Link } from "react-router"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import Color from "color"; import cx from "classnames"; diff --git a/frontend/src/metabase/questions/components/Item.jsx b/frontend/src/metabase/questions/components/Item.jsx index b8f5003d4fd7c20809de6f5504c96cc032442fd3..0f24593d33436ab5a8f50609763634927756e878 100644 --- a/frontend/src/metabase/questions/components/Item.jsx +++ b/frontend/src/metabase/questions/components/Item.jsx @@ -14,7 +14,7 @@ import MoveToCollection from "../containers/MoveToCollection.jsx"; import Labels from "./Labels.jsx"; import CollectionBadge from "./CollectionBadge.jsx"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; const ITEM_ICON_SIZE = 20; @@ -122,7 +122,7 @@ Item.propTypes = { const ItemBody = pure(({ entity, id, name, description, labels, favorite, collection, setFavorited, onEntityClick }) => <div className={S.itemBody}> <div className={cx('flex', S.itemTitle)}> - <Link to={Urls.card(id)} className={cx(S.itemName)} onClick={onEntityClick && ((e) => { e.preventDefault(); onEntityClick(entity); })}> + <Link to={Urls.question(id)} className={cx(S.itemName)} onClick={onEntityClick && ((e) => { e.preventDefault(); onEntityClick(entity); })}> {name} </Link> { collection && diff --git a/frontend/src/metabase/questions/components/Labels.jsx b/frontend/src/metabase/questions/components/Labels.jsx index cadf7680243fc2acd3dc436d3c45eac04b524f1f..dabc0137ce8a198158efdd891d5a8ebef9123e32 100644 --- a/frontend/src/metabase/questions/components/Labels.jsx +++ b/frontend/src/metabase/questions/components/Labels.jsx @@ -3,7 +3,7 @@ import React, { Component, PropTypes } from "react"; import { Link } from "react-router"; import S from "./Labels.css"; import color from 'color' -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import EmojiIcon from "metabase/components/EmojiIcon.jsx" diff --git a/frontend/src/metabase/questions/questions.js b/frontend/src/metabase/questions/questions.js index 9d1970c246abe09a32a35cda55ae91100af7ecfe..056815d94ee2c1828401aafc862f112d7af16502 100644 --- a/frontend/src/metabase/questions/questions.js +++ b/frontend/src/metabase/questions/questions.js @@ -7,7 +7,7 @@ import _ from "underscore"; import { inflect } from "metabase/lib/formatting"; import MetabaseAnalytics from "metabase/lib/analytics"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import { push, replace } from "react-router-redux"; import { setRequestState } from "metabase/redux/requests"; diff --git a/frontend/src/metabase/reference/components/GuideDetail.jsx b/frontend/src/metabase/reference/components/GuideDetail.jsx index bbb27cc09b74ceff2c85ff22c45cd4925aa6add1..faf42ad83f95d13464616e6e2b550c21fc823ad3 100644 --- a/frontend/src/metabase/reference/components/GuideDetail.jsx +++ b/frontend/src/metabase/reference/components/GuideDetail.jsx @@ -2,7 +2,9 @@ import React, { Component, PropTypes } from "react"; import { Link } from "react-router"; import pure from "recompose/pure"; import cx from "classnames"; + import Icon from "metabase/components/Icon" +import * as Urls from "metabase/lib/urls"; import { getQuestionUrl, @@ -21,7 +23,7 @@ const GuideDetail = ({ const title = entity.display_name || entity.name; const { caveats, points_of_interest } = entity; const typeToLink = { - dashboard: `/dash/${entity.id}`, + dashboard: Urls.dashboard(entity.id), metric: getQuestionUrl({ dbId: tables[entity.table_id] && tables[entity.table_id].db_id, tableId: entity.table_id, diff --git a/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx b/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx index daf3818bd173b97955d390ef587e863b4299d9a7..9f821341533866af791d903eec787c720cec6106 100644 --- a/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx @@ -5,6 +5,7 @@ import moment from "moment"; import visualizations from "metabase/visualizations"; import { isQueryable } from "metabase/lib/table"; +import * as Urls from "metabase/lib/urls"; import S from "metabase/components/List.css"; import R from "metabase/reference/Reference.css"; @@ -57,7 +58,7 @@ const createListItem = (entity, index, section) => } url={section.type !== 'questions' ? `${section.id}/${entity.id}` : - `/card/${entity.id}` + Urls.question(entity.id) } icon={section.type === 'questions' ? visualizations.get(entity.display).iconName : diff --git a/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx b/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx index a5640fb8d7295fb1b3d616207ce54a3a38edbcef..b34c861b019d881c0797b701c203b252bc49b08f 100644 --- a/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx @@ -4,10 +4,12 @@ import { Link } from "react-router"; import { connect } from 'react-redux'; import { push } from 'react-router-redux'; import { reduxForm } from "redux-form"; + import { assoc } from "icepick"; import cx from "classnames"; import MetabaseAnalytics from "metabase/lib/analytics"; +import * as Urls from "metabase/lib/urls"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx"; import CreateDashboardModal from 'metabase/components/CreateDashboardModal.jsx'; @@ -225,7 +227,7 @@ export default class ReferenceGettingStartedGuide extends Component { createDashboardFn={async (newDashboard) => { try { const action = await createDashboard(newDashboard, true); - push(`/dash/${action.payload.id}`); + push(Urls.dashboard(action.payload.id)); } catch(error) { console.error(error); @@ -632,4 +634,4 @@ const AdminInstructions = ({ children }) => // eslint-disable-line react/prop-ty </div> const SectionHeader = ({ trim, children }) => // eslint-disable-line react/prop-types - <h2 className={cx('text-dark text-measure', { "mb0" : trim }, { "mb4" : !trim })}>{children}</h2> + <h2 className={cx('text-dark text-measure', { "mb0" : trim }, { "mb4" : !trim })}>{children}</h2> diff --git a/frontend/src/metabase/reference/utils.js b/frontend/src/metabase/reference/utils.js index e22840da4f48bf99e05eb563c75c6951927bee43..0a85e12cc89dcc5ec8ef40b417179cc031cae011 100644 --- a/frontend/src/metabase/reference/utils.js +++ b/frontend/src/metabase/reference/utils.js @@ -2,8 +2,9 @@ import { assoc, assocIn, chain } from "icepick"; import _ from "underscore"; import { titleize, humanize } from "metabase/lib/formatting"; -import { startNewCard, serializeCardForUrl } from "metabase/lib/card"; +import { startNewCard } from "metabase/lib/card"; import { isPK } from "metabase/lib/types"; +import * as Urls from "metabase/lib/urls"; export const idsToObjectMap = (ids, objects) => ids .map(id => objects[id]) @@ -76,11 +77,10 @@ export const tryUpdateData = async (fields, props) => { const importantFieldIds = fields.important_fields.map(field => field.id); const existingImportantFieldIds = guide.metric_important_fields && guide.metric_important_fields[entity.id]; - const areFieldIdsIdentitical = existingImportantFieldIds && + const areFieldIdsIdentitical = existingImportantFieldIds && existingImportantFieldIds.length === importantFieldIds.length && existingImportantFieldIds.every(id => importantFieldIds.includes(id)); - - console.log(areFieldIdsIdentitical); + if (!areFieldIdsIdentitical) { await updateMetricImportantFields(entity.id, importantFieldIds); tryFetchData(props); @@ -156,8 +156,8 @@ export const tryUpdateGuide = async (formFields, props) => { startLoading(); try { const updateNewEntities = ({ - entities, - formFields, + entities, + formFields, updateEntity }) => formFields.map(formField => { if (!formField.id) { @@ -175,7 +175,7 @@ export const tryUpdateGuide = async (formFields, props) => { const newEntity = entities[formField.id]; const updatedNewEntity = { - ...newEntity, + ...newEntity, ...editedEntity }; @@ -185,9 +185,9 @@ export const tryUpdateGuide = async (formFields, props) => { }); const updateOldEntities = ({ - newEntityIds, - oldEntityIds, - entities, + newEntityIds, + oldEntityIds, + entities, updateEntity }) => oldEntityIds .filter(oldEntityId => !newEntityIds.includes(oldEntityId)) @@ -201,14 +201,14 @@ export const tryUpdateGuide = async (formFields, props) => { ); const updatingOldEntity = updateEntity(updatedOldEntity); - + return [updatingOldEntity]; }); //FIXME: necessary because revision_message is a mandatory field // even though we don't actually keep track of changes to caveats/points_of_interest yet const updateWithRevisionMessage = updateEntity => entity => updateEntity(assoc( entity, - 'revision_message', + 'revision_message', 'Updated in Getting Started guide.' )); @@ -218,9 +218,9 @@ export const tryUpdateGuide = async (formFields, props) => { updateEntity: updateDashboard }) .concat(updateOldEntities({ - newEntityIds: formFields.most_important_dashboard ? + newEntityIds: formFields.most_important_dashboard ? [formFields.most_important_dashboard.id] : [], - oldEntityIds: guide.most_important_dashboard ? + oldEntityIds: guide.most_important_dashboard ? [guide.most_important_dashboard] : [], entities: dashboards, @@ -239,7 +239,7 @@ export const tryUpdateGuide = async (formFields, props) => { entities: metrics, updateEntity: updateWithRevisionMessage(updateMetric) })); - + const updatingMetricImportantFields = formFields.important_metrics .map(metricFormField => { if (!metricFormField.id || !metricFormField.important_fields) { @@ -248,17 +248,17 @@ export const tryUpdateGuide = async (formFields, props) => { const importantFieldIds = metricFormField.important_fields .map(field => field.id); const existingImportantFieldIds = guide.metric_important_fields[metricFormField.id]; - - const areFieldIdsIdentitical = existingImportantFieldIds && + + const areFieldIdsIdentitical = existingImportantFieldIds && existingImportantFieldIds.length === importantFieldIds.length && existingImportantFieldIds.every(id => importantFieldIds.includes(id)); if (areFieldIdsIdentitical) { return []; } - + return [updateMetricImportantFields(metricFormField.id, importantFieldIds)]; }); - + const segmentFields = formFields.important_segments_and_tables .filter(field => field.type === 'segment'); @@ -299,7 +299,7 @@ export const tryUpdateGuide = async (formFields, props) => { guide.contact.name !== formFields.contact.name ? [updateSetting({key: 'getting-started-contact-name', value: formFields.contact.name })] : []; - + const updatingContactEmail = guide.contact && formFields.contact && guide.contact.email !== formFields.contact.email ? [updateSetting({key: 'getting-started-contact-email', value: formFields.contact.email })] : @@ -318,7 +318,7 @@ export const tryUpdateGuide = async (formFields, props) => { if (updatingData.length > 0) { await Promise.all(updatingData); - + clearRequestState({statePath: ['reference', 'guide']}); await fetchGuide(); @@ -431,7 +431,7 @@ export const getQuestion = ({dbId, tableId, fieldId, metricId, segmentId, getCou return question; }; -export const getQuestionUrl = getQuestionArgs => `/q#${serializeCardForUrl(getQuestion(getQuestionArgs))}`; +export const getQuestionUrl = getQuestionArgs => Urls.question(null, getQuestion(getQuestionArgs)); export const isGuideEmpty = ({ things_to_know, @@ -446,7 +446,7 @@ export const isGuideEmpty = ({ most_important_dashboard ? false : important_metrics && important_metrics.length !== 0 ? false : important_segments && important_segments.length !== 0 ? false : - important_tables && important_tables.length !== 0 ? false : + important_tables && important_tables.length !== 0 ? false : true; export const typeToLinkClass = { diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index 26285e188981bda6c8cadddadaa8053905382c08..1be0d20d54d4ef1eafad19b5edd4864de11cb470 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -151,11 +151,11 @@ export const getRoutes = (store) => <Route path="/" component={HomepageApp} /> {/* DASHBOARD */} - <Route path="/dash/:dashboardId" component={DashboardApp} /> + <Route path="/dashboard/:dashboardId" component={DashboardApp} /> {/* QUERY BUILDER */} - <Route path="/card/:cardId" component={QueryBuilder} /> - <Route path="/q" component={QueryBuilder} /> + <Route path="/question" component={QueryBuilder} /> + <Route path="/question/:cardId" component={QueryBuilder} /> {/* QUESTIONS */} <Route path="/questions"> @@ -262,13 +262,13 @@ export const getRoutes = (store) => <IndexRedirect to="/_internal/list" /> </Route> + {/* DEPRECATED */} + <Redirect from="/q" to="/question" /> + <Redirect from="/card/:cardId" to="/question/:cardId" /> + <Redirect from="/dash/:dashboardId" to="/dashboard/:dashboardId" /> + {/* MISC */} <Route path="/unauthorized" component={Unauthorized} /> <Route path="/*" component={NotFound} /> - - {/* LEGACY */} - <Redirect from="/card" to="/questions" /> - <Redirect from="/card/:cardId/:serializedCard" to="/questions/:cardId#:serializedCard" /> - <Redirect from="/q/:serializedCard" to="/q#:serializedCard" /> </Route> </Route> diff --git a/frontend/src/metabase/visualizations/components/LegendHeader.jsx b/frontend/src/metabase/visualizations/components/LegendHeader.jsx index 0d29843b595d4362aef8c5523adc295b86978fbe..3574ac8732835fdb54eeb4788714f09b12e5fef5 100644 --- a/frontend/src/metabase/visualizations/components/LegendHeader.jsx +++ b/frontend/src/metabase/visualizations/components/LegendHeader.jsx @@ -5,7 +5,7 @@ import styles from "./Legend.css"; import Icon from "metabase/components/Icon.jsx"; import LegendItem from "./LegendItem.jsx"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import cx from "classnames"; @@ -65,7 +65,7 @@ export default class LegendHeader extends Component { key={index} title={s.card.name} description={description} - href={linkToCard && s.card.id && Urls.card(s.card.id)} + href={linkToCard && s.card.id && Urls.question(s.card.id)} color={colors[index % colors.length]} showDot={showDots} showTitle={showTitles} diff --git a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx index 33dda37c911ae1e6c651788909cefd9e5ad26151..0109394d22e216b61bba2ae5395b0ce4892f1645 100644 --- a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx @@ -8,7 +8,7 @@ import Icon from "metabase/components/Icon.jsx"; import Tooltip from "metabase/components/Tooltip.jsx"; import Ellipsified from "metabase/components/Ellipsified.jsx"; -import Urls from "metabase/lib/urls"; +import * as Urls from "metabase/lib/urls"; import { formatValue } from "metabase/lib/formatting"; import { TYPE } from "metabase/lib/types"; import { isNumber } from "metabase/lib/schema_metadata"; @@ -195,7 +195,7 @@ export default class Scalar extends Component<*, VisualizationProps, *> { <div className={styles.Title + " flex align-center"}> <Ellipsified tooltip={card.name}> { linkToCard ? - <Link to={Urls.card(card.id)} className="no-decoration fullscreen-normal-text fullscreen-night-text">{settings["card.title"]}</Link> + <Link to={Urls.question(card.id)} className="no-decoration fullscreen-normal-text fullscreen-night-text">{settings["card.title"]}</Link> : <span className="fullscreen-normal-text fullscreen-night-text">{settings["card.title"]}</span> } diff --git a/frontend/test/e2e/auth/login.spec.js b/frontend/test/e2e/auth/login.spec.js index d67f0476d4aff0f55317f1b3a5c71743fda8e77e..fe191cebf9c5107762286b3346a5890b20aed86a 100644 --- a/frontend/test/e2e/auth/login.spec.js +++ b/frontend/test/e2e/auth/login.spec.js @@ -55,8 +55,8 @@ describeE2E("auth/login", () => { }); it("loads the qb", async () => { - await driver.get(`${server.host}/q#eyJuYW1lIjpudWxsLCJkYXRhc2V0X3F1ZXJ5Ijp7ImRhdGFiYXNlIjoxLCJ0eXBlIjoibmF0aXZlIiwibmF0aXZlIjp7InF1ZXJ5Ijoic2VsZWN0ICdvaCBoYWkgZ3Vpc2Ug8J-QsScifSwicGFyYW1ldGVycyI6W119LCJkaXNwbGF5Ijoic2NhbGFyIiwidmlzdWFsaXphdGlvbl9zZXR0aW5ncyI6e319`); - await waitForUrl(driver, `${server.host}/q#eyJuYW1lIjpudWxsLCJkYXRhc2V0X3F1ZXJ5Ijp7ImRhdGFiYXNlIjoxLCJ0eXBlIjoibmF0aXZlIiwibmF0aXZlIjp7InF1ZXJ5Ijoic2VsZWN0ICdvaCBoYWkgZ3Vpc2Ug8J-QsScifSwicGFyYW1ldGVycyI6W119LCJkaXNwbGF5Ijoic2NhbGFyIiwidmlzdWFsaXphdGlvbl9zZXR0aW5ncyI6e319`); + await driver.get(`${server.host}/question#eyJuYW1lIjpudWxsLCJkYXRhc2V0X3F1ZXJ5Ijp7ImRhdGFiYXNlIjoxLCJ0eXBlIjoibmF0aXZlIiwibmF0aXZlIjp7InF1ZXJ5Ijoic2VsZWN0ICdvaCBoYWkgZ3Vpc2Ug8J-QsScifSwicGFyYW1ldGVycyI6W119LCJkaXNwbGF5Ijoic2NhbGFyIiwidmlzdWFsaXphdGlvbl9zZXR0aW5ncyI6e319`); + await waitForUrl(driver, `${server.host}/question#eyJuYW1lIjpudWxsLCJkYXRhc2V0X3F1ZXJ5Ijp7ImRhdGFiYXNlIjoxLCJ0eXBlIjoibmF0aXZlIiwibmF0aXZlIjp7InF1ZXJ5Ijoic2VsZWN0ICdvaCBoYWkgZ3Vpc2Ug8J-QsScifSwicGFyYW1ldGVycyI6W119LCJkaXNwbGF5Ijoic2NhbGFyIiwidmlzdWFsaXphdGlvbl9zZXR0aW5ncyI6e319`); await screenshot(driver, "screenshots/qb.png"); }); }); diff --git a/frontend/test/e2e/query_builder/query_builder.spec.js b/frontend/test/e2e/query_builder/query_builder.spec.js index 6e4106bd8722b4ed842020f59468a80d715e3e2a..9eb6aa8d69d39a6dc0d9d572ca0f2113db7db8ee 100644 --- a/frontend/test/e2e/query_builder/query_builder.spec.js +++ b/frontend/test/e2e/query_builder/query_builder.spec.js @@ -15,7 +15,7 @@ describeE2E("query_builder", () => { describe("tables", () => { it("should allow users to create pivot tables", async () => { // load the query builder and screenshot blank - await d.get("/q"); + await d.get("/question"); await d.screenshot("screenshots/qb-initial.png"); // pick the orders table (assumes database is already selected, i.e. there's only 1 database) @@ -56,7 +56,7 @@ describeE2E("query_builder", () => { describe("charts", () => { xit("should allow users to create line charts", async () => { - await d.get("/q"); + await d.get("/question"); // select orders table await d.select("#TablePicker .List-item:first-child>a").wait().click(); @@ -106,7 +106,7 @@ describeE2E("query_builder", () => { xit("should allow users to create bar charts", async () => { // load line chart - await d.get("/card/2"); + await d.get("/question/2"); // dismiss saved questions modal await d.select(".Modal .Button.Button--primary").wait().click(); diff --git a/frontend/test/e2e/query_builder/tutorial.spec.js b/frontend/test/e2e/query_builder/tutorial.spec.js index 8b6b31f589a2becb8170a0bac563025c3ad08501..9298adaf0a18211a64e7647e82286bf14ddf5504 100644 --- a/frontend/test/e2e/query_builder/tutorial.spec.js +++ b/frontend/test/e2e/query_builder/tutorial.spec.js @@ -24,7 +24,7 @@ describeE2E("tutorial", () => { await waitForElementAndClick(driver, ".Modal .Button.Button--primary"); await waitForElementAndClick(driver, ".Modal .Button.Button--primary"); - await waitForUrl(driver, `${server.host}/q`); + await waitForUrl(driver, `${server.host}/question`); await waitForElement(driver, ".Modal .Button.Button--primary"); await screenshot(driver, "screenshots/setup-tutorial-qb.png"); await waitForElementAndClick(driver, ".Modal .Button.Button--primary"); diff --git a/src/metabase/util/urls.clj b/src/metabase/util/urls.clj index db1c2948f50289fd71a4222729b61d59259f920c..3c27b5ee2033afecfc12bad23d45101565f0e1e0 100644 --- a/src/metabase/util/urls.clj +++ b/src/metabase/util/urls.clj @@ -18,16 +18,16 @@ (defn dashboard-url "Return an appropriate URL for a `Dashboard` with ID. - (dashboard-url 10) -> \"http://localhost:3000/dash/10\"" + (dashboard-url 10) -> \"http://localhost:3000/dashboard/10\"" [^Integer id] - (format "%s/dash/%d" (public-settings/site-url) id)) + (format "%s/dashboard/%d" (public-settings/site-url) id)) (defn card-url "Return an appropriate URL for a `Card` with ID. - (card-url 10) -> \"http://localhost:3000/card/10\"" + (card-url 10) -> \"http://localhost:3000/question/10\"" [^Integer id] - (format "%s/card/%d" (public-settings/site-url) id)) + (format "%s/question/%d" (public-settings/site-url) id)) (defn segment-url "Return an appropriate URL for a `Segment` with ID.