diff --git a/e2e/support/helpers/e2e-downloads-helpers.js b/e2e/support/helpers/e2e-downloads-helpers.js index 4aa251c521bdff60c4e49a374d1f2ff1eda4cb81..402b13989aa71c4b97d589c8243412896415a82d 100644 --- a/e2e/support/helpers/e2e-downloads-helpers.js +++ b/e2e/support/helpers/e2e-downloads-helpers.js @@ -29,6 +29,7 @@ export function downloadAndAssert( downloadUrl, isEmbed, isDashboard, + enableFormatting = true, } = {}, callback, ) { @@ -76,7 +77,7 @@ export function downloadAndAssert( cy.findByTestId("download-button").click(); } // Initiate the file download - popover().findByText(`.${fileType}`).click(); + popover().findByText(`.${fileType}`).click({ altKey: !enableFormatting }); cy.wait("@fileDownload") .its("request") @@ -110,15 +111,15 @@ export function assertSheetRowsCount(expectedCount) { function getEndpoint(fileType, questionId, publicUid, dashcardId, dashboardId) { if (dashcardId != null && dashboardId != null) { - return `api/dashboard/${dashboardId}/dashcard/${dashcardId}/card/${questionId}/query/${fileType}`; + return `api/dashboard/${dashboardId}/dashcard/${dashcardId}/card/${questionId}/query/${fileType}**`; } if (publicUid) { return `/public/question/${publicUid}.${fileType}**`; } - const questionEndpoint = `/api/card/${questionId}/query/${fileType}`; - const queryEndpoint = `/api/dataset/${fileType}`; + const questionEndpoint = `/api/card/${questionId}/query/${fileType}**`; + const queryEndpoint = `/api/dataset/${fileType}**`; return questionId ? questionEndpoint : queryEndpoint; } diff --git a/e2e/test/scenarios/sharing/downloads/downloads.cy.spec.js b/e2e/test/scenarios/sharing/downloads/downloads.cy.spec.js index 03f4631a99340a6ab52f1b2e54517ad28722e675..659ed34c4bd221c612097bb04b8d3a68d518eb71 100644 --- a/e2e/test/scenarios/sharing/downloads/downloads.cy.spec.js +++ b/e2e/test/scenarios/sharing/downloads/downloads.cy.spec.js @@ -21,6 +21,8 @@ import { resetSnowplow, enableTracking, addOrUpdateDashboardCard, + createQuestion, + queryBuilderMain, } from "e2e/support/helpers"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; @@ -75,6 +77,60 @@ describe("scenarios > question > download", () => { }); }); + it("should allow downloading unformatted CSV data", () => { + const fieldRef = ["field", ORDERS.TOTAL, null]; + const columnKey = `["ref",${JSON.stringify(fieldRef)}]`; + + createQuestion( + { + query: { + "source-table": ORDERS_ID, + fields: [fieldRef], + }, + visualization_settings: { + column_settings: { + [columnKey]: { + currency: "USD", + currency_in_header: false, + currency_style: "code", + number_style: "currency", + }, + }, + }, + }, + { visitQuestion: true, wrapId: true }, + ); + + queryBuilderMain().findByText("USD 39.72").should("exist"); + + cy.get("@questionId").then(questionId => { + const opts = { questionId, fileType: "csv" }; + + downloadAndAssert( + { + ...opts, + enableFormatting: true, + }, + sheet => { + expect(sheet["A1"].v).to.eq("Total"); + expect(sheet["A2"].v).to.eq("USD 39.72"); + }, + ); + + downloadAndAssert( + { + ...opts, + enableFormatting: false, + }, + sheet => { + expect(sheet["A1"].v).to.eq("Total"); + expect(sheet["A2"].v).to.eq(39.718145389078366); + expect(sheet["A2"].w).to.eq("39.718145389078366"); + }, + ); + }); + }); + describe("from dashboards", () => { it("should allow downloading card data", () => { cy.intercept("GET", "/api/dashboard/**").as("dashboard"); diff --git a/frontend/src/metabase/dashboard/components/DashCard/DashCardMenu/DashCardMenu.tsx b/frontend/src/metabase/dashboard/components/DashCard/DashCardMenu/DashCardMenu.tsx index 47e06df7d833128488769f651f50876e4a9feba2..1553fe057121c282672284b5d9c033ef52c5819a 100644 --- a/frontend/src/metabase/dashboard/components/DashCard/DashCardMenu/DashCardMenu.tsx +++ b/frontend/src/metabase/dashboard/components/DashCard/DashCardMenu/DashCardMenu.tsx @@ -64,9 +64,9 @@ const DashCardMenu = ({ onDownloadResults, }: DashCardMenuProps) => { const [{ loading }, handleDownload] = useAsyncFn( - async (type: string) => { + async (opts: { type: string; enableFormatting: boolean }) => { await onDownloadResults({ - type, + ...opts, question, result, dashboardId, @@ -84,9 +84,9 @@ const DashCardMenu = ({ <QueryDownloadPopover question={question} result={result} - onDownload={type => { + onDownload={opts => { toggleMenu(); - handleDownload(type); + handleDownload(opts); }} /> ), diff --git a/frontend/src/metabase/query_builder/actions/downloading.ts b/frontend/src/metabase/query_builder/actions/downloading.ts index 7dd7c66f46abbae1a428bdcc6cdee29a977cc70b..8c9bb43bb5d89021a96dd4c21067852a7d2db410 100644 --- a/frontend/src/metabase/query_builder/actions/downloading.ts +++ b/frontend/src/metabase/query_builder/actions/downloading.ts @@ -17,6 +17,7 @@ export interface DownloadQueryResultsOpts { type: string; question: Question; result: Dataset; + enableFormatting?: boolean; dashboardId?: DashboardId; dashcardId?: DashCardId; uuid?: string; @@ -28,7 +29,8 @@ export interface DownloadQueryResultsOpts { interface DownloadQueryResultsParams { method: string; url: string; - params: URLSearchParams; + body?: Record<string, unknown>; + params?: URLSearchParams; } export const downloadQueryResults = @@ -59,6 +61,7 @@ const getDatasetParams = ({ question, dashboardId, dashcardId, + enableFormatting, uuid, token, params = {}, @@ -67,11 +70,15 @@ const getDatasetParams = ({ }: DownloadQueryResultsOpts): DownloadQueryResultsParams => { const cardId = question.id(); const isSecureDashboardEmbedding = dashcardId != null && token != null; + + // Formatting is always enabled for Excel + const format_rows = enableFormatting && type !== "xlsx" ? "true" : "false"; + if (isSecureDashboardEmbedding) { return { method: "GET", url: `/api/embed/dashboard/${token}/dashcard/${dashcardId}/card/${cardId}/${type}`, - params: Urls.getEncodedUrlSearchParams(params), + params: Urls.getEncodedUrlSearchParams({ ...params, format_rows }), }; } @@ -80,9 +87,10 @@ const getDatasetParams = ({ return { method: "POST", url: `/api/dashboard/${dashboardId}/dashcard/${dashcardId}/card/${cardId}/query/${type}`, - params: new URLSearchParams({ - parameters: JSON.stringify(result?.json_query?.parameters ?? []), - }), + params: new URLSearchParams({ format_rows }), + body: { + parameters: result?.json_query?.parameters ?? [], + }, }; } @@ -93,6 +101,7 @@ const getDatasetParams = ({ url: Urls.publicQuestion({ uuid, type, includeSiteUrl: false }), params: new URLSearchParams({ parameters: JSON.stringify(result?.json_query?.parameters ?? []), + format_rows, }), }; } @@ -101,10 +110,12 @@ const getDatasetParams = ({ if (isEmbeddedQuestion) { // For whatever wacky reason the /api/embed endpoint expect params like ?key=value instead // of like ?params=<json-encoded-params-array> like the other endpoints do. + const params = new URLSearchParams(window.location.search); + params.set("format_rows", format_rows); return { method: "GET", url: Urls.embedCard(token, type), - params: new URLSearchParams(window.location.search), + params, }; } @@ -113,41 +124,53 @@ const getDatasetParams = ({ return { method: "POST", url: `/api/card/${cardId}/query/${type}`, - params: new URLSearchParams({ - parameters: JSON.stringify(result?.json_query?.parameters ?? []), - }), + params: new URLSearchParams({ format_rows }), + body: { + parameters: result?.json_query?.parameters ?? [], + }, }; } return { - url: `/api/dataset/${type}`, method: "POST", - params: new URLSearchParams({ - query: JSON.stringify(_.omit(result?.json_query ?? {}, "constraints")), - visualization_settings: JSON.stringify(visualizationSettings ?? {}), - }), + url: `/api/dataset/${type}`, + params: new URLSearchParams({ format_rows }), + body: { + query: _.omit(result?.json_query ?? {}, "constraints"), + visualization_settings: visualizationSettings ?? {}, + }, }; }; -export function getDatasetDownloadUrl(url: string) { - // make url relative if it's not - url = url.replace(api.basename, ""); - const requestUrl = new URL(api.basename + url, location.origin); - +export function getDatasetDownloadUrl(url: string, params?: URLSearchParams) { + url = url.replace(api.basename, ""); // make url relative if it's not + url = api.basename + url; + if (params) { + url += `?${params.toString()}`; + } + const requestUrl = new URL(url, location.origin); return requestUrl.href; } const getDatasetResponse = ({ url, method, + body, params, }: DownloadQueryResultsParams) => { - const requestUrl = getDatasetDownloadUrl(url); + const requestUrl = getDatasetDownloadUrl(url, params); if (method === "POST") { - return fetch(requestUrl, { method, body: params }); + // BE expects the body to be form-encoded :( + const formattedBody = new URLSearchParams(); + if (body != null) { + for (const key in body) { + formattedBody.append(key, JSON.stringify(body[key])); + } + } + return fetch(requestUrl, { method, body: formattedBody }); } else { - return fetch(`${requestUrl}?${params}`); + return fetch(requestUrl); } }; diff --git a/frontend/src/metabase/query_builder/components/QueryDownloadPopover/QueryDownloadPopover.styled.tsx b/frontend/src/metabase/query_builder/components/QueryDownloadPopover/QueryDownloadPopover.styled.tsx index a7cf1a771677433f3047511561b9ff60b3c44817..63cc524e7a56f9a977f80552d603ec3c6f41e30a 100644 --- a/frontend/src/metabase/query_builder/components/QueryDownloadPopover/QueryDownloadPopover.styled.tsx +++ b/frontend/src/metabase/query_builder/components/QueryDownloadPopover/QueryDownloadPopover.styled.tsx @@ -12,6 +12,10 @@ export const DownloadPopoverRoot = styled.div<DownloadPopoverRootProps>` `; export const DownloadPopoverHeader = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; padding: 0.5rem; `; @@ -22,7 +26,7 @@ export const DownloadPopoverMessage = styled.div` export const DownloadButtonRoot = styled.button` display: flex; align-items: center; - gap: 0.5rem; + justify-content: space-between; width: 100%; margin: 0.5rem 0; padding: 0.5rem; @@ -42,3 +46,12 @@ export const DownloadButtonText = styled.div` color: ${color("white")}; } `; + +export const DownloadButtonSecondaryText = styled.div` + color: ${color("text-light")}; + font-weight: bold; + + ${DownloadButtonRoot}:hover & { + color: ${color("white")}; + } +`; diff --git a/frontend/src/metabase/query_builder/components/QueryDownloadPopover/QueryDownloadPopover.tsx b/frontend/src/metabase/query_builder/components/QueryDownloadPopover/QueryDownloadPopover.tsx index 6b513e15911d3b81c6fe4d8fc1a06f8d5bfb6666..5b27b095894b73e9f0eaed2f1fd34fa0fa582257 100644 --- a/frontend/src/metabase/query_builder/components/QueryDownloadPopover/QueryDownloadPopover.tsx +++ b/frontend/src/metabase/query_builder/components/QueryDownloadPopover/QueryDownloadPopover.tsx @@ -1,9 +1,12 @@ -import { useCallback } from "react"; +import { useState } from "react"; import { connect } from "react-redux"; +import { useEvent } from "react-use"; import { t } from "ttag"; +import { isMac } from "metabase/lib/browser"; import { exportFormats, exportFormatPng } from "metabase/lib/urls"; import { PLUGIN_FEATURE_LEVEL_PERMISSIONS } from "metabase/plugins"; +import { Icon, Tooltip, useHover } from "metabase/ui"; import { canSavePng } from "metabase/visualizations"; import type Question from "metabase-lib/v1/Question"; import type { Dataset } from "metabase-types/api"; @@ -11,6 +14,7 @@ import type { State } from "metabase-types/store"; import { DownloadButtonRoot, + DownloadButtonSecondaryText, DownloadButtonText, DownloadPopoverHeader, DownloadPopoverMessage, @@ -20,7 +24,7 @@ import { interface OwnProps { question: Question; result: Dataset; - onDownload: (format: string) => void; + onDownload: (opts: { type: string; enableFormatting: boolean }) => void; } interface StateProps { @@ -43,20 +47,53 @@ const mapStateToProps = ( t`The maximum download size is 1 million rows.`, }); +// Excel and images always use formatting +const checkCanManageFormatting = (format: string) => + format !== "xlsx" && format !== "png"; + const QueryDownloadPopover = ({ canDownloadPng, hasTruncatedResults, limitedDownloadSizeText, onDownload, }: QueryDownloadPopoverProps) => { + const [isHoldingAltKey, setHoldingAltKey] = useState(false); + + useEvent("keydown", event => { + if (event.key === "Alt") { + setHoldingAltKey(true); + } + }); + + useEvent("keyup", event => { + if (event.key === "Alt") { + setHoldingAltKey(false); + } + }); + const formats = canDownloadPng ? [...exportFormats, exportFormatPng] : exportFormats; + const handleDownload = (type: string, enableFormatting: boolean) => { + const canManageFormatting = checkCanManageFormatting(type); + onDownload({ + type, + enableFormatting: canManageFormatting ? enableFormatting : true, + }); + }; + + const formattingInfoTooltipLabel = isMac() + ? t`Option click to download without formatting applied` + : t`Alt click to download without formatting applied`; + return ( <DownloadPopoverRoot isExpanded={hasTruncatedResults}> <DownloadPopoverHeader> <h4>{t`Download full results`}</h4> + <Tooltip label={formattingInfoTooltipLabel}> + <Icon name="info_filled" /> + </Tooltip> </DownloadPopoverHeader> {hasTruncatedResults && ( <DownloadPopoverMessage> @@ -65,7 +102,13 @@ const QueryDownloadPopover = ({ </DownloadPopoverMessage> )} {formats.map(format => ( - <DownloadButton key={format} format={format} onDownload={onDownload} /> + <DownloadButton + key={format} + format={format} + hasUnformattedOption={checkCanManageFormatting(format)} + isHoldingAltKey={isHoldingAltKey} + onDownload={handleDownload} + /> ))} </DownloadPopoverRoot> ); @@ -73,17 +116,28 @@ const QueryDownloadPopover = ({ interface DownloadButtonProps { format: string; - onDownload: (format: string) => void; + hasUnformattedOption: boolean; + isHoldingAltKey: boolean; + onDownload: (format: string, enableFormatting: boolean) => void; } -const DownloadButton = ({ format, onDownload }: DownloadButtonProps) => { - const handleClick = useCallback(() => { - onDownload(format); - }, [format, onDownload]); +const DownloadButton = ({ + format, + hasUnformattedOption, + isHoldingAltKey, + onDownload, +}: DownloadButtonProps) => { + const { hovered, ref } = useHover<HTMLButtonElement>(); return ( - <DownloadButtonRoot onClick={handleClick}> + <DownloadButtonRoot + onClick={event => onDownload(format, !event.altKey)} + ref={ref} + > <DownloadButtonText>.{format}</DownloadButtonText> + {hasUnformattedOption && isHoldingAltKey && hovered && ( + <DownloadButtonSecondaryText>{t`(Unformatted)`}</DownloadButtonSecondaryText> + )} </DownloadButtonRoot> ); }; diff --git a/frontend/src/metabase/query_builder/components/QueryDownloadPopover/QueryDownloadPopover.unit.spec.tsx b/frontend/src/metabase/query_builder/components/QueryDownloadPopover/QueryDownloadPopover.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0a098dadd2e67b39a9383700ab5c2438cc79ebf2 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/QueryDownloadPopover/QueryDownloadPopover.unit.spec.tsx @@ -0,0 +1,129 @@ +import userEvent from "@testing-library/user-event"; + +import { setupCardQueryDownloadEndpoint } from "__support__/server-mocks"; +import { createMockEntitiesState } from "__support__/store"; +import { act, fireEvent, renderWithProviders, screen } from "__support__/ui"; +import { checkNotNull } from "metabase/lib/types"; +import { getMetadata } from "metabase/selectors/metadata"; +import registerVisualizations from "metabase/visualizations/register"; +import type { Card, Dataset } from "metabase-types/api"; +import { + createMockCard, + createMockDataset, + createMockStructuredDatasetQuery, +} from "metabase-types/api/mocks"; +import { ORDERS_ID, SAMPLE_DB_ID } from "metabase-types/api/mocks/presets"; +import { createMockState } from "metabase-types/store/mocks"; + +import QueryDownloadPopover from "./QueryDownloadPopover"; + +registerVisualizations(); + +const TEST_CARD = createMockCard({ + dataset_query: createMockStructuredDatasetQuery({ + database: SAMPLE_DB_ID, + query: { + "source-table": ORDERS_ID, + }, + }), +}); + +const TEST_RESULT = createMockDataset(); + +interface SetupOpts { + card?: Card; + result?: Dataset; +} + +const setup = ({ card = TEST_CARD, result = TEST_RESULT }: SetupOpts = {}) => { + const onDownload = jest.fn(); + + const state = createMockState({ + entities: createMockEntitiesState({ + questions: [card], + }), + }); + + const metadata = getMetadata(state); + const question = checkNotNull(metadata.question(card.id)); + + setupCardQueryDownloadEndpoint(card, "json"); + + renderWithProviders( + <QueryDownloadPopover + question={question} + result={result} + onDownload={onDownload} + />, + ); + + return { onDownload }; +}; + +describe("QueryDownloadPopover", () => { + it("should display download options", async () => { + setup(); + + expect(screen.getByText(/csv/)).toBeInTheDocument(); + expect(screen.getByText(/xlsx/)).toBeInTheDocument(); + expect(screen.getByText(/json/)).toBeInTheDocument(); + }); + + it("should suggest PNG downloads for compatible visualizations", async () => { + setup({ card: { ...TEST_CARD, display: "line" } }); + expect(screen.getByText(/png/)).toBeInTheDocument(); + }); + + it("should not suggest PNG downloads for incompatible visualizations", async () => { + setup({ card: { ...TEST_CARD, display: "table" } }); + expect(screen.queryByText(/png/)).not.toBeInTheDocument(); + }); + + it("should trigger download on click", async () => { + const { onDownload } = setup(); + act(() => userEvent.click(screen.getByText(/csv/))); + expect(onDownload).toHaveBeenCalledWith({ + type: "csv", + enableFormatting: true, + }); + }); + + it.each(["csv", "json"])( + "should trigger unformatted download for %s format", + async format => { + const { onDownload } = setup(); + + expect(screen.queryByText(/Unformatted/i)).not.toBeInTheDocument(); + await fireEvent.keyDown(screen.getByText(/csv/), { key: "Alt" }); + act(() => userEvent.hover(screen.getByText(/csv/))); + expect(await screen.findByText(/Unformatted/i)).toBeInTheDocument(); + + act(() => + userEvent.click(screen.getByText(new RegExp(format)), { altKey: true }), + ); + + expect(onDownload).toHaveBeenCalledWith({ + type: format, + enableFormatting: false, + }); + }, + ); + + it.each(["xlsx", "png"])( + "should not trigger unformatted download for %s format", + async format => { + const { onDownload } = setup({ card: { ...TEST_CARD, display: "line" } }); + + expect(screen.queryByText(/Unformatted/i)).not.toBeInTheDocument(); + await fireEvent.keyDown(screen.getByText(/csv/), { key: "Alt" }); + expect(screen.queryByText(/Unformatted/i)).not.toBeInTheDocument(); + + act(() => userEvent.click(screen.getByText(new RegExp(format)))); + + expect(onDownload).toHaveBeenCalledWith({ + type: format, + enableFormatting: true, + }); + }, + ); +}); diff --git a/frontend/src/metabase/query_builder/components/QueryDownloadWidget/QueryDownloadWidget.tsx b/frontend/src/metabase/query_builder/components/QueryDownloadWidget/QueryDownloadWidget.tsx index 97940800ac19f5bb97854f406973829a86a45d59..d4c31cdfb8c6c7c4c6d68986c2469a6b5720ab11 100644 --- a/frontend/src/metabase/query_builder/components/QueryDownloadWidget/QueryDownloadWidget.tsx +++ b/frontend/src/metabase/query_builder/components/QueryDownloadWidget/QueryDownloadWidget.tsx @@ -50,9 +50,9 @@ const QueryDownloadWidget = ({ const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [{ loading }, handleDownload] = useAsyncFn( - async (type: string) => { + async (opts: { type: string; enableFormatting: boolean }) => { await onDownload({ - type, + ...opts, question, result, dashboardId, @@ -89,9 +89,9 @@ const QueryDownloadWidget = ({ <QueryDownloadPopover question={question} result={result} - onDownload={type => { + onDownload={opts => { setIsPopoverOpen(false); - handleDownload(type); + handleDownload(opts); }} /> </Popover.Dropdown> diff --git a/frontend/src/metabase/sharing/components/EmailAttachmentPicker.jsx b/frontend/src/metabase/sharing/components/EmailAttachmentPicker.jsx index 9f48f0189bd7facc078d6cc9ecbfb776a3d457be..c3fa1d04e3c00b92f98a4b6635038e7a118fe0ac 100644 --- a/frontend/src/metabase/sharing/components/EmailAttachmentPicker.jsx +++ b/frontend/src/metabase/sharing/components/EmailAttachmentPicker.jsx @@ -16,6 +16,7 @@ export default class EmailAttachmentPicker extends Component { state = { isEnabled: false, + isFormattingEnabled: true, selectedAttachmentType: this.DEFAULT_ATTACHMENT_TYPE, selectedCardIds: new Set(), }; @@ -54,6 +55,7 @@ export default class EmailAttachmentPicker extends Component { selectedAttachmentType: this.attachmentTypeFor(selectedCards) || this.DEFAULT_ATTACHMENT_TYPE, selectedCardIds: new Set(selectedCards.map(card => card.id)), + isFormattingEnabled: getInitialFormattingState(selectedCards), }; } @@ -71,6 +73,8 @@ export default class EmailAttachmentPicker extends Component { */ updatePulseCards(attachmentType, selectedCardIds) { const { pulse, setPulse } = this.props; + const { isFormattingEnabled } = this.state; + const isXls = attachmentType === "xls", isCsv = attachmentType === "csv"; @@ -81,6 +85,7 @@ export default class EmailAttachmentPicker extends Component { cards: pulse.cards.map(card => { card.include_csv = selectedCardIds.has(card.id) && isCsv; card.include_xls = selectedCardIds.has(card.id) && isXls; + card.format_rows = isCsv && isFormattingEnabled; // Excel always uses formatting return card; }), }); @@ -165,6 +170,21 @@ export default class EmailAttachmentPicker extends Component { }); }; + onToggleFormatting = () => { + this.setState( + prevState => ({ + ...prevState, + isFormattingEnabled: !prevState.isFormattingEnabled, + }), + () => { + this.updatePulseCards( + this.state.selectedAttachmentType, + this.state.selectedCardIds, + ); + }, + ); + }; + disableAllCards() { const selectedCardIds = new Set(); this.updatePulseCards(this.state.selectedAttachmentType, selectedCardIds); @@ -181,7 +201,12 @@ export default class EmailAttachmentPicker extends Component { render() { const { cards } = this.props; - const { isEnabled, selectedAttachmentType, selectedCardIds } = this.state; + const { + isEnabled, + isFormattingEnabled, + selectedAttachmentType, + selectedCardIds, + } = this.state; return ( <div> @@ -205,6 +230,15 @@ export default class EmailAttachmentPicker extends Component { fullWidth /> </div> + {selectedAttachmentType === "csv" && ( + <div className={cx(CS.mt2, CS.mb3)}> + <CheckBox + checked={!isFormattingEnabled} + label={t`Use unformatted values in attachments`} + onChange={this.onToggleFormatting} + /> + </div> + )} <div className={cx( CS.textBold, @@ -264,3 +298,10 @@ export default class EmailAttachmentPicker extends Component { ); } } + +function getInitialFormattingState(cards) { + if (cards.length > 0) { + return cards.some(card => !!card.format_rows); + } + return true; +} diff --git a/frontend/src/metabase/sharing/components/SharingSidebar/SharingSidebar.jsx b/frontend/src/metabase/sharing/components/SharingSidebar/SharingSidebar.jsx index 230cc868f6b17098f118a3d6b01e9841f7877463..f415880162c40a85014149e69d62016daaaf594c 100644 --- a/frontend/src/metabase/sharing/components/SharingSidebar/SharingSidebar.jsx +++ b/frontend/src/metabase/sharing/components/SharingSidebar/SharingSidebar.jsx @@ -77,6 +77,7 @@ const cardsToPulseCards = (cards, pulseCards) => { const pulseCard = pulseCards.find(pc => pc.id === card.id) || card; return { ...card, + format_rows: pulseCard.format_rows, include_csv: pulseCard.include_csv, include_xls: pulseCard.include_xls, }; diff --git a/frontend/src/metabase/ui/index.ts b/frontend/src/metabase/ui/index.ts index 33b227c6fbd27f6f3f9e83cf46799ede4ec11e81..49e195bf4d0beaf54861fa2b235ce1130b1ccf28 100644 --- a/frontend/src/metabase/ui/index.ts +++ b/frontend/src/metabase/ui/index.ts @@ -1,2 +1,3 @@ export { rem } from "@mantine/core"; +export { useHover } from "@mantine/hooks"; export * from "./components"; diff --git a/resources/migrations/001_update_migrations.yaml b/resources/migrations/001_update_migrations.yaml index b2ffa389095608cbcb165f57c54733d920ba96be..58d7a515c9a7d349d30965ac065e8ddd144c8691 100644 --- a/resources/migrations/001_update_migrations.yaml +++ b/resources/migrations/001_update_migrations.yaml @@ -5529,6 +5529,20 @@ databaseChangeLog: sql: INSERT INTO setting ("KEY", "VALUE") VALUES ('enable-public-sharing', 'false'); rollback: # not needed + - changeSet: + id: v49.2024-03-26T10:23:12 + author: adam-james + comment: Add pulse_card.format_rows + changes: + - addColumn: + tableName: pulse_card + columns: + - column: + name: format_rows + type: ${boolean.type} + defaultValue: true + remarks: Whether or not to apply formatting to the rows of the export + - changeSet: id: v49.2024-03-26T20:27:58 author: noahmoss diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index 4205ba7e8538f56dcce65c1b3321d86cb26b961d..741514fd9718c8585e0927abfbacea91cde786de 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -672,9 +672,10 @@ `parameters` should be passed as query parameter encoded as a serialized JSON string (this is because this endpoint is normally used to power 'Download Results' buttons that use HTML `form` actions)." - [card-id export-format :as {{:keys [parameters]} :params}] + [card-id export-format :as {{:keys [parameters format_rows]} :params}] {card-id ms/PositiveInt parameters [:maybe ms/JSONString] + format_rows [:maybe :boolean] export-format (into [:enum] api.dataset/export-formats)} (qp.card/process-query-for-card card-id export-format @@ -684,7 +685,7 @@ :middleware {:process-viz-settings? true :skip-results-metadata? true :ignore-cached-results? true - :format-rows? false + :format-rows? format_rows :js-int-to-string? false})) ;;; ----------------------------------------------- Sharing is Caring ------------------------------------------------ diff --git a/src/metabase/api/dashboard.clj b/src/metabase/api/dashboard.clj index 7b66fd4bcdf98e4bbe2b6bf6459457ba193db9d0..fc987517da9f1e07e5345d1de7411f859db85648 100644 --- a/src/metabase/api/dashboard.clj +++ b/src/metabase/api/dashboard.clj @@ -1160,12 +1160,13 @@ `parameters` should be passed as query parameter encoded as a serialized JSON string (this is because this endpoint is normally used to power 'Download Results' buttons that use HTML `form` actions)." - [dashboard-id dashcard-id card-id export-format :as {{:keys [parameters], :as request-parameters} :params}] + [dashboard-id dashcard-id card-id export-format :as {{:keys [parameters format_rows], :as request-parameters} :params}] {dashboard-id ms/PositiveInt dashcard-id ms/PositiveInt card-id ms/PositiveInt parameters [:maybe ms/JSONString] - export-format api.dataset/ExportFormat} + export-format api.dataset/ExportFormat + format_rows [:maybe :boolean]} (m/mapply qp.dashboard/process-query-for-dashcard (merge request-parameters @@ -1182,7 +1183,7 @@ :middleware {:process-viz-settings? true :skip-results-metadata? true :ignore-cached-results? true - :format-rows? false + :format-rows? format_rows :js-int-to-string? false}}))) (api/defendpoint POST "/pivot/:dashboard-id/dashcard/:dashcard-id/card/:card-id/query" diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj index 43f5e05fef435df3140624ba356a81c6eab64fdb..3cfcb37d942382500eeeabaaa68ffef843c65607 100644 --- a/src/metabase/api/dataset.clj +++ b/src/metabase/api/dataset.clj @@ -125,9 +125,11 @@ (api/defendpoint POST ["/:export-format", :export-format export-format-regex] "Execute a query and download the result data as a file in the specified format." - [export-format :as {{:keys [query visualization_settings] :or {visualization_settings "{}"}} :params}] + [export-format :as {{:keys [query visualization_settings format_rows] + :or {visualization_settings "{}"}} :params}] {query ms/JSONString visualization_settings ms/JSONString + format_rows [:maybe :boolean] export-format (into [:enum] export-formats)} (let [query (json/parse-string query keyword) viz-settings (-> (json/parse-string visualization_settings viz-setting-key-fn) @@ -140,7 +142,7 @@ (dissoc :add-default-userland-constraints? :js-int-to-string?) (assoc :process-viz-settings? true :skip-results-metadata? true - :format-rows? false))))] + :format-rows? format_rows))))] (run-streaming-query (qp/userland-query query) :export-format export-format diff --git a/src/metabase/api/embed.clj b/src/metabase/api/embed.clj index 4ec632ec9b5155984e530ce0e081df228d584d1a..532cfb0df2cff77822e7b66188bc2ca590e3eb78 100644 --- a/src/metabase/api/embed.clj +++ b/src/metabase/api/embed.clj @@ -406,8 +406,9 @@ (api/defendpoint GET ["/card/:token/query/:export-format", :export-format api.dataset/export-format-regex] "Like `GET /api/embed/card/query`, but returns the results as a file in the specified format." - [token export-format :as {:keys [query-params]}] - {export-format (into [:enum] api.dataset/export-formats)} + [token export-format :as {:keys [query-params format_rows]}] + {export-format (into [:enum] api.dataset/export-formats) + format_rows [:maybe :boolean]} (run-query-for-unsigned-token-async (embed/unsign token) export-format @@ -415,7 +416,7 @@ :constraints nil :middleware {:process-viz-settings? true :js-int-to-string? false - :format-rows? false})) + :format-rows? format_rows})) ;;; ----------------------------------------- /api/embed/dashboard endpoints ----------------------------------------- @@ -459,7 +460,7 @@ :card-id card-id :embedding-params (t2/select-one-fn :embedding_params Dashboard :id dashboard-id) :token-params (embed/get-in-unsigned-token-or-throw unsigned-token [:params]) - :query-params query-params + :query-params (dissoc query-params :format_rows) :constraints constraints :qp qp :middleware middleware))) @@ -556,9 +557,10 @@ :export-format api.dataset/export-format-regex] "Fetch the results of running a Card belonging to a Dashboard using a JSON Web Token signed with the `embedding-secret-key` return the data in one of the export formats" - [token export-format dashcard-id card-id, :as {:keys [query-params]}] + [token export-format dashcard-id card-id format_rows, :as {:keys [query-params]}] {dashcard-id ms/PositiveInt card-id ms/PositiveInt + format_rows [:maybe :boolean] export-format (into [:enum] api.dataset/export-formats)} (process-query-for-dashcard-with-signed-token token dashcard-id @@ -568,7 +570,7 @@ :constraints nil :middleware {:process-viz-settings? true :js-int-to-string? false - :format-rows? false})) + :format-rows? format_rows})) ;;; ----------------------------------------------- Param values ------------------------------------------------- diff --git a/src/metabase/api/public.clj b/src/metabase/api/public.clj index d3193d55fa8bc4c7510f6d4d45b8f9e66dda8234..e2d56da2770769b1ffa9ae3d2363588149a5c4d8 100644 --- a/src/metabase/api/public.clj +++ b/src/metabase/api/public.clj @@ -175,9 +175,10 @@ (api/defendpoint GET "/card/:uuid/query/:export-format" "Fetch a publicly-accessible Card and return query results in the specified format. Does not require auth credentials. Public sharing must be enabled." - [uuid export-format :as {{:keys [parameters]} :params}] + [uuid export-format :as {{:keys [parameters format_rows]} :params}] {uuid ms/UUIDString export-format api.dataset/ExportFormat + format_rows [:maybe :boolean] parameters [:maybe ms/JSONString]} (process-query-for-card-with-public-uuid uuid @@ -186,7 +187,7 @@ :constraints nil :middleware {:process-viz-settings? true :js-int-to-string? false - :format-rows? false})) + :format-rows? format_rows})) ;;; ----------------------------------------------- Public Dashboards ------------------------------------------------ diff --git a/src/metabase/email/messages.clj b/src/metabase/email/messages.clj index 44760486694d87a783d2c84f49e89087e154f09b..74a9f8fb999c738b4917e2f3b138a64160a8ff12 100644 --- a/src/metabase/email/messages.clj +++ b/src/metabase/email/messages.clj @@ -30,7 +30,6 @@ [metabase.query-processor.store :as qp.store] [metabase.query-processor.streaming :as qp.streaming] [metabase.query-processor.streaming.interface :as qp.si] - [metabase.query-processor.streaming.xlsx :as qp.xlsx] [metabase.query-processor.timezone :as qp.timezone] [metabase.util :as u] [metabase.util.date-2 :as u.date] @@ -403,39 +402,41 @@ point in the future; for now, this function is a stopgap. Results are streamed synchronously. Caller is responsible for closing `os` when this call is complete." - [export-format ^OutputStream os {{:keys [rows]} :data, database-id :database_id, :as results}] + [export-format format-rows? ^OutputStream os {{:keys [rows]} :data, database-id :database_id, :as results}] ;; make sure Database/driver info is available for the streaming results writers -- they might need this in order to ;; get timezone information when writing results (driver/with-driver (driver.u/database->driver database-id) (qp.store/with-metadata-provider database-id - (binding [qp.xlsx/*parse-temporal-string-values* true] - (let [w (qp.si/streaming-results-writer export-format os) - cols (-> results :data :cols) - viz-settings (-> results :data :viz-settings) - [ordered-cols output-order] (qp.streaming/order-cols cols viz-settings) - viz-settings' (assoc viz-settings :output-order output-order)] - (qp.si/begin! w - (assoc-in results [:data :ordered-cols] ordered-cols) - viz-settings') - (dorun - (map-indexed - (fn [i row] - (qp.si/write-row! w row i ordered-cols viz-settings')) - rows)) - (qp.si/finish! w results)))))) + (let [w (qp.si/streaming-results-writer export-format os) + cols (-> results :data :cols) + viz-settings (-> results :data :viz-settings) + [ordered-cols output-order] (qp.streaming/order-cols cols viz-settings) + viz-settings' (assoc viz-settings :output-order output-order)] + (qp.si/begin! w + (-> results + (assoc-in [:data :format-rows?] format-rows?) + (assoc-in [:data :ordered-cols] ordered-cols)) + viz-settings') + (dorun + (map-indexed + (fn [i row] + (qp.si/write-row! w row i ordered-cols viz-settings')) + rows)) + (qp.si/finish! w results))))) (defn- result-attachment - [{{card-name :name :as card} :card {{:keys [rows]} :data :as result} :result}] + [{{card-name :name format-rows :format_rows :as card} :card + {{:keys [rows]} :data :as result} :result}] (when (seq rows) [(when-let [temp-file (and (:include_csv card) (create-temp-file-or-throw "csv"))] (with-open [os (io/output-stream temp-file)] - (stream-api-results-to-export-format :csv os result)) + (stream-api-results-to-export-format :csv format-rows os result)) (create-result-attachment-map "csv" card-name temp-file)) (when-let [temp-file (and (:include_xls card) (create-temp-file-or-throw "xlsx"))] (with-open [os (io/output-stream temp-file)] - (stream-api-results-to-export-format :xlsx os result)) + (stream-api-results-to-export-format :xlsx format-rows os result)) (create-result-attachment-map "xlsx" card-name temp-file))])) (defn- part-attachments [parts] @@ -518,7 +519,7 @@ (for [{{result-card-id :id} :card :as result} results :let [pulse-card (m/find-first #(= (:id %) result-card-id) (:cards pulse))]] (if result-card-id - (update result :card merge (select-keys pulse-card [:include_csv :include_xls])) + (update result :card merge (select-keys pulse-card [:include_csv :include_xls :format_rows])) result))) (defn render-pulse-email diff --git a/src/metabase/legacy_mbql/schema.cljc b/src/metabase/legacy_mbql/schema.cljc index 6bd5d6956deae00bb5062094df9ca92c231592d4..2fd071ce6f5bd51222f40084b5ab4ecea66fbbc2 100644 --- a/src/metabase/legacy_mbql/schema.cljc +++ b/src/metabase/legacy_mbql/schema.cljc @@ -1522,9 +1522,11 @@ ;; should we skip adding results_metadata to query results after running the query? Used by ;; [[metabase.query-processor.middleware.results-metadata]]; default `false` [:skip-results-metadata? {:optional true} :boolean] - ;; should we skip converting datetime types to ISO-8601 strings with appropriate timezone when post-processing - ;; results? Used by [[metabase.query-processor.middleware.format-rows]]; default `false` - [:format-rows? {:optional true} :boolean] + ;; should we convert datetime types to ISO-8601 strings with appropriate timezone when post-processing results? + ;; additionally, should we apply formatting specified in column/viz settings to exports (csv/json)? + ;; See [[metabase.query-processor.streaming.csv]] + ;; Used by [[metabase.query-processor.middleware.format-rows]]; defaults to `true` + [:format-rows? {:optional true} [:maybe :boolean]] ;; disable the MBQL->native middleware. If you do this, the query will not work at all, so there are no cases where ;; you should set this yourself. This is only used by the [[metabase.query-processor.preprocess/preprocess]] ;; function to get the fully pre-processed query without attempting to convert it to native. diff --git a/src/metabase/models/pulse.clj b/src/metabase/models/pulse.clj index 282e725073ba6206026ea1dcbd5084a782699362..de167bbc194328d28eb293b327ce61072136661e 100644 --- a/src/metabase/models/pulse.clj +++ b/src/metabase/models/pulse.clj @@ -180,6 +180,7 @@ [:map [:include_csv ms/BooleanValue] [:include_xls ms/BooleanValue] + [:format_rows {:optional true} [:maybe ms/BooleanValue]] [:dashboard_card_id {:optional true} [:maybe ms/PositiveInt]]] (deferred-tru "value must be a map with the keys `{0}`, `{1}`, and `{2}`." "include_csv" "include_xls" "dashboard_card_id"))) @@ -232,7 +233,7 @@ [pulse-ids] (t2/select :model/Card - {:select [:c.id :c.name :c.description :c.collection_id :c.display :pc.include_csv :pc.include_xls + {:select [:c.id :c.name :c.description :c.collection_id :c.display :pc.include_csv :pc.include_xls :pc.format_rows :pc.dashboard_card_id :dc.dashboard_id [nil :parameter_mappings] [:p.id :pulse_id]] ;; :dc.parameter_mappings - how do you select this? :from [[:pulse :p]] :join [[:pulse_card :pc] [:= :p.id :pc.pulse_id] @@ -416,6 +417,7 @@ {:id (u/the-id card) :include_csv (get card :include_csv false) :include_xls (get card :include_xls false) + :format_rows (get card :format_rows true) :dashboard_card_id (get card :dashboard_card_id nil)}) @@ -433,12 +435,13 @@ (t2/delete! PulseCard :pulse_id (u/the-id notification-or-id)) ;; now just insert all of the cards that were given to us (when (seq card-refs) - (let [cards (map-indexed (fn [i {card-id :id :keys [include_csv include_xls dashboard_card_id]}] + (let [cards (map-indexed (fn [i {card-id :id :keys [include_csv include_xls format_rows dashboard_card_id]}] {:pulse_id (u/the-id notification-or-id) :card_id card-id :position i :include_csv include_csv :include_xls include_xls + :format_rows format_rows :dashboard_card_id dashboard_card_id}) card-refs)] (t2/insert! PulseCard cards)))) diff --git a/src/metabase/models/pulse_card.clj b/src/metabase/models/pulse_card.clj index ed0ae801abaf2776427a36177171bf71cb16ae61..2ce2866a0dbc4634745ca390129caff5ed9b6a6f 100644 --- a/src/metabase/models/pulse_card.clj +++ b/src/metabase/models/pulse_card.clj @@ -40,20 +40,22 @@ [:dashboard_card_id ms/PositiveInt] [:position {:optional true} [:maybe ms/IntGreaterThanOrEqualToZero]] [:include_csv {:optional true} [:maybe :boolean]] - [:include_xls {:optional true} [:maybe :boolean]]]) + [:include_xls {:optional true} [:maybe :boolean]] + [:format_rows {:optional true} [:maybe :boolean]]]) (mu/defn bulk-create! "Creates new PulseCards, joining the given card, pulse, and dashboard card and setting appropriate defaults for other values if they're not provided." [new-pulse-cards :- [:sequential NewPulseCard]] (t2/insert! PulseCard - (for [{:keys [card_id pulse_id dashboard_card_id position include_csv include_xls]} new-pulse-cards] + (for [{:keys [card_id pulse_id dashboard_card_id position include_csv include_xls format_rows]} new-pulse-cards] {:card_id card_id :pulse_id pulse_id :dashboard_card_id dashboard_card_id :position (u/or-with some? position (next-position-for pulse_id)) :include_csv (boolean include_csv) - :include_xls (boolean include_xls)}))) + :include_xls (boolean include_xls) + :format_rows (boolean format_rows)}))) ; ----------------------------------------------------- Serialization ------------------------------------------------- diff --git a/src/metabase/query_processor/middleware/format_rows.clj b/src/metabase/query_processor/middleware/format_rows.clj index 8ab3a14c45c853990e82e66a1f9a43ffda7ef554..bc8abfb46a5e77e83d6ab7314f1925bd2df91a7b 100644 --- a/src/metabase/query_processor/middleware/format_rows.clj +++ b/src/metabase/query_processor/middleware/format_rows.clj @@ -76,7 +76,10 @@ (defn format-rows "Format individual query result values as needed. Ex: format temporal values as ISO-8601 strings w/ timezone offset." [{{:keys [format-rows?] :or {format-rows? true}} :middleware, :as _query} rff] - (if format-rows? - (fn format-rows-rff* [metadata] - (format-rows-xform (rff metadata) metadata)) - rff)) + (fn format-rows-rff* [metadata] + ;; always assoc `:format-rows?` into the metadata so that + ;; the `qp.si/streaming-results-writer` implmementations can apply/not-apply formatting based on the key's value + (let [metadata (assoc metadata :format-rows? format-rows?)] + (if format-rows? + (format-rows-xform (rff metadata) metadata) + (rff metadata))))) diff --git a/src/metabase/query_processor/streaming/csv.clj b/src/metabase/query_processor/streaming/csv.clj index 30ea9cf8260e6a1d6ba643a61fccff1b6bb25f4c..937d1158e9effcafce89308f8f4504c67c42c8a5 100644 --- a/src/metabase/query_processor/streaming/csv.clj +++ b/src/metabase/query_processor/streaming/csv.clj @@ -29,11 +29,13 @@ (let [writer (BufferedWriter. (OutputStreamWriter. os StandardCharsets/UTF_8)) ordered-formatters (volatile! nil)] (reify qp.si/StreamingResultsWriter - (begin! [_ {{:keys [ordered-cols results_timezone]} :data} viz-settings] + (begin! [_ {{:keys [ordered-cols results_timezone format-rows?] + :or {format-rows? true}} :data} viz-settings] (let [col-names (common/column-titles ordered-cols (::mb.viz/column-settings viz-settings))] - (vreset! ordered-formatters (mapv (fn [col] - (formatter/create-formatter results_timezone col viz-settings)) - ordered-cols)) + (vreset! ordered-formatters + (if format-rows? + (mapv #(formatter/create-formatter results_timezone % viz-settings) ordered-cols) + (vec (repeat (count ordered-cols) identity)))) (csv/write-csv writer [col-names]) (.flush writer))) diff --git a/src/metabase/query_processor/streaming/json.clj b/src/metabase/query_processor/streaming/json.clj index 1a48839c06869c34b28579799a42bb80e3871b45..f505dcf629f5e0633f1013fe3a8f5c70af83d4ee 100644 --- a/src/metabase/query_processor/streaming/json.clj +++ b/src/metabase/query_processor/streaming/json.clj @@ -31,13 +31,15 @@ col-names (volatile! nil) ordered-formatters (volatile! nil)] (reify qp.si/StreamingResultsWriter - (begin! [_ {{:keys [ordered-cols results_timezone]} :data} viz-settings] + (begin! [_ {{:keys [ordered-cols results_timezone format-rows?] + :or {format-rows? true}} :data} viz-settings] ;; TODO -- wouldn't it make more sense if the JSON downloads used `:name` preferentially? Seeing how JSON is ;; probably going to be parsed programmatically (vreset! col-names (common/column-titles ordered-cols (::mb.viz/column-settings viz-settings))) - (vreset! ordered-formatters (mapv (fn [col] - (formatter/create-formatter results_timezone col viz-settings)) - ordered-cols)) + (vreset! ordered-formatters + (if format-rows? + (mapv #(formatter/create-formatter results_timezone % viz-settings) ordered-cols) + (vec (repeat (count ordered-cols) identity)))) (.write writer "[\n")) (write-row! [_ row row-num _ {:keys [output-order]}] diff --git a/src/metabase/query_processor/streaming/xlsx.clj b/src/metabase/query_processor/streaming/xlsx.clj index 15a5c465ae0b3100f6025b1892fe0d87784a6afd..f4a3afc1cc171b02fa0f4721b6a09dddc0501561 100644 --- a/src/metabase/query_processor/streaming/xlsx.clj +++ b/src/metabase/query_processor/streaming/xlsx.clj @@ -368,11 +368,6 @@ (defmethod set-cell! nil [^Cell cell _value _styles _typed-styles] (.setBlank cell)) -(def ^:dynamic *parse-temporal-string-values* - "When true, XLSX exports will attempt to parse string values into corresponding java.time classes so that - formatting can be applied. This should be enabled for generation of pulse/dashboard subscription attachments." - false) - (defn- maybe-parse-coordinate-value [value {:keys [semantic_type]}] (when (isa? semantic_type :type/Coordinate) (try (formatter/format-geographic-coordinates semantic_type value) @@ -381,9 +376,15 @@ value)))) (defn- maybe-parse-temporal-value + "The format-rows qp middleware formats rows into strings, which circumvents the formatting done in this namespace. + To gain the formatting back, we parse the temporal strings back into their java.time objects. + + TODO: find a way to avoid this java.time -> string -> java.time conversion by making sure the format-rows middlware + works effectively with the streaming-results-writer implementations for CSV, JSON, and XLSX. + A hint towards a better solution is to add into the format-rows middleware the use of + viz-settings/column-formatting that is used inside `metabase.formatter/create-formatter`." [value col] - (when (and *parse-temporal-string-values* - (isa? (:effective_type col) :type/Temporal) + (when (and (isa? (or (:base_type col) (:effective_type col)) :type/Temporal) (string? value)) (try (u.date/parse value) ;; Fallback to plain string value if it couldn't be parsed diff --git a/test/metabase/api/alert_test.clj b/test/metabase/api/alert_test.clj index 1a7e3443ce60594e781c4b5fc4f79ff0efe51ec0..61dd8ea95dcc115aeaeb4ef395981e6ceb8a99ba 100644 --- a/test/metabase/api/alert_test.clj +++ b/test/metabase/api/alert_test.clj @@ -34,7 +34,7 @@ (select-keys [:name :description :display]) (update :display name) (update :collection_id boolean) - (assoc :id true, :include_csv false, :include_xls false, :dashboard_card_id false, + (assoc :id true, :include_csv false, :include_xls false, :format_rows true, :dashboard_card_id false, :dashboard_id false, :parameter_mappings nil))) (defn- recipient-details [user-kwd] diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index 625c4d1bfe8a8428bbfaeb323e87a0bed337e815..d8522320367b6e62300623c9b88274e6540fae8b 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -2,6 +2,7 @@ "Tests for /api/card endpoints." (:require [cheshire.core :as json] + [clojure.data.csv :as csv] [clojure.set :as set] [clojure.string :as str] [clojure.test :refer :all] @@ -3514,3 +3515,22 @@ :visualization_settings {}}] (is (=? {:message #"Invalid Field Filter: Field \d+ \"VENUES\"\.\"NAME\" belongs to Database \d+ \"test-data\", but the query is against Database \d+ \"daily-bird-counts\""} (mt/user-http-request :crowberto :post 400 "card" card-data))))))) + +(deftest ^:parallel format-export-middleware-test + (testing "The `:format-export?` query processor middleware has the intended effect on file exports." + (let [q {:database (mt/id) + :type :native + :native {:query "SELECT 2000 AS number, '2024-03-26'::DATE AS date;"}} + output-helper {:csv (fn [output] (->> output csv/read-csv last)) + :json (fn [output] (->> output (map (juxt :NUMBER :DATE)) last))}] + (t2.with-temp/with-temp [Card {card-id :id} {:display :table :dataset_query q}] + (doseq [[export-format apply-formatting? expected] [[:csv true ["2,000" "March 26, 2024"]] + [:csv false ["2000" "2024-03-26"]] + [:json true ["2,000" "March 26, 2024"]] + [:json false [2000 "2024-03-26"]]]] + (testing (format "export_format %s yields expected output for %s exports." apply-formatting? export-format) + (is (= expected + (->> (mt/user-http-request + :crowberto :post 200 + (format "card/%s/query/%s?format_rows=%s" card-id (name export-format) apply-formatting?)) + ((get output-helper export-format))))))))))) diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj index 7a7125f6f51403c9edf539a2c283cdcd5a11e019..d37ff3997c0a6e94283149fb4880aebc01f67871 100644 --- a/test/metabase/api/dashboard_test.clj +++ b/test/metabase/api/dashboard_test.clj @@ -2,6 +2,7 @@ "Tests for /api/dashboard endpoints." (:require [cheshire.core :as json] + [clojure.data.csv :as csv] [clojure.set :as set] [clojure.string :as str] [clojure.test :refer :all] @@ -4363,3 +4364,25 @@ (is (=? {:data {:rows [["1" "Red Medicine" "4" 10.0646 -165.374 3] ["2" "Stout Burgers & Beers" "11" 34.0996 -118.329 2]]}} (mt/user-http-request :crowberto :post 202 (dashboard-card-query-url dashboard-id card-id dashcard-id))))))))) + +(deftest ^:parallel format-export-middleware-test + (testing "The `:format-export?` query processor middleware has the intended effect on file exports." + (let [q {:database (mt/id) + :type :native + :native {:query "SELECT 2000 AS number, '2024-03-26'::DATE AS date;"}} + output-helper {:csv (fn [output] (->> output csv/read-csv last)) + :json (fn [output] (->> output (map (juxt :NUMBER :DATE)) last))}] + (t2.with-temp/with-temp [Card {card-id :id} {:display :table :dataset_query q} + Dashboard {dashboard-id :id} {} + DashboardCard {dashcard-id :id} {:dashboard_id dashboard-id + :card_id card-id}] + (doseq [[export-format apply-formatting? expected] [[:csv true ["2,000" "March 26, 2024"]] + [:csv false ["2000" "2024-03-26"]] + [:json true ["2,000" "March 26, 2024"]] + [:json false [2000 "2024-03-26"]]]] + (testing (format "export_format %s yields expected output for %s exports." apply-formatting? export-format) + (is (= expected + (->> (mt/user-http-request + :crowberto :post 200 + (format "/dashboard/%s/dashcard/%s/card/%s/query/%s?format_rows=%s" dashboard-id dashcard-id card-id (name export-format) apply-formatting?)) + ((get output-helper export-format))))))))))) diff --git a/test/metabase/api/dataset_test.clj b/test/metabase/api/dataset_test.clj index 435e85ee52274577d4bb16f2e9bd4148fd6e528f..faec1dafbe32a8a28811261f1786f8bf8103e979 100644 --- a/test/metabase/api/dataset_test.clj +++ b/test/metabase/api/dataset_test.clj @@ -634,3 +634,22 @@ (some->> s (driver/prettify-native-form :h2) str/split-lines)))))))))) + +(deftest ^:parallel format-export-middleware-test + (testing "The `:format-export?` query processor middleware has the intended effect on file exports." + (let [q {:database (mt/id) + :type :native + :native {:query "SELECT 2000 AS number, '2024-03-26'::DATE AS date;"}} + output-helper {:csv (fn [output] (->> output csv/read-csv last)) + :json (fn [output] (->> output (map (juxt :NUMBER :DATE)) last))}] + (doseq [[export-format apply-formatting? expected] [[:csv true ["2,000" "March 26, 2024"]] + [:csv false ["2000" "2024-03-26"]] + [:json true ["2,000" "March 26, 2024"]] + [:json false [2000 "2024-03-26"]]]] + (testing (format "export_format %s yields expected output for %s exports." apply-formatting? export-format) + (is (= expected + (->> (mt/user-http-request + :crowberto :post 200 + (format "dataset/%s?format_rows=%s" (name export-format) apply-formatting?) + :query (json/generate-string q)) + ((get output-helper export-format)))))))))) diff --git a/test/metabase/api/embed_test.clj b/test/metabase/api/embed_test.clj index 5598019973a8974402d737ccb4d1eab3e25787ed..13466a46585bafc544b2c6cb7c8db75aa61a04a2 100644 --- a/test/metabase/api/embed_test.clj +++ b/test/metabase/api/embed_test.clj @@ -1599,3 +1599,28 @@ :embedding_params {:qty_locked "locked"}}] (is (= [3443] (mt/first-row (client/client :get 202 (card-query-url card "" {:params {:qty_locked 1}})))))))))) + +(deftest format-export-middleware-test + (testing "The `:format-export?` query processor middleware has the intended effect on file exports." + (let [q {:database (mt/id) + :type :native + :native {:query "SELECT 2000 AS number, '2024-03-26'::DATE AS date;"}} + output-helper {:csv (fn [output] (->> output csv/read-csv last)) + :json (fn [output] (->> output (map (juxt :NUMBER :DATE)) last))}] + (with-embedding-enabled-and-new-secret-key + (t2.with-temp/with-temp [Card {card-id :id} {:display :table :dataset_query q} + Dashboard {dashboard-id :id} {:enable_embedding true + :embedding_params {:name "enabled"}} + DashboardCard {dashcard-id :id} {:dashboard_id dashboard-id + :card_id card-id}] + (doseq [[export-format apply-formatting? expected] [[:csv true ["2,000" "March 26, 2024"]] + [:csv false ["2000" "2024-03-26"]] + [:json true ["2,000" "March 26, 2024"]] + [:json false [2000 "2024-03-26"]]]] + (testing (format "export_format %s yields expected output for %s exports." apply-formatting? export-format) + (is (= expected + (->> (mt/user-http-request + :crowberto :get 200 + (format "embed/dashboard/%s/dashcard/%s/card/%s/%s?format_rows=%s" + (dash-token dashboard-id) dashcard-id card-id (name export-format) apply-formatting?)) + ((get output-helper export-format)))))))))))) diff --git a/test/metabase/api/public_test.clj b/test/metabase/api/public_test.clj index 25e15b988366483051a2d101ebf0a464476903bc..09aa1ca722778c913813a44d5e0ac4d03b511ced 100644 --- a/test/metabase/api/public_test.clj +++ b/test/metabase/api/public_test.clj @@ -2,6 +2,7 @@ "Tests for `api/public/` (public links) endpoints." (:require [cheshire.core :as json] + [clojure.data.csv :as csv] [clojure.set :as set] [clojure.string :as str] [clojure.test :refer :all] @@ -1736,3 +1737,23 @@ "type" "query"} :user-id nil} (last (snowplow-test/pop-event-data-and-user-id!)))))))))))) + +(deftest format-export-middleware-test + (mt/with-temporary-setting-values [enable-public-sharing true] + (testing "The `:format-export?` query processor middleware has the intended effect on file exports." + (let [q {:database (mt/id) + :type :native + :native {:query "SELECT 2000 AS number, '2024-03-26'::DATE AS date;"}} + output-helper {:csv (fn [output] (->> output csv/read-csv last)) + :json (fn [output] (->> output (map (juxt :NUMBER :DATE)) last))}] + (with-temp-public-card [{uuid :public_uuid} {:display :table :dataset_query q}] + (doseq [[export-format apply-formatting? expected] [[:csv true ["2,000" "March 26, 2024"]] + [:csv false ["2000" "2024-03-26"]] + [:json true ["2,000" "March 26, 2024"]] + [:json false [2000 "2024-03-26"]]]] + (testing (format "export_format %s yields expected output for %s exports." apply-formatting? export-format) + (is (= expected + (->> (mt/user-http-request + :crowberto :get 200 + (format "public/card/%s/query/%s?format_rows=%s" uuid (name export-format) apply-formatting?)) + ((get output-helper export-format)))))))))))) diff --git a/test/metabase/api/pulse_test.clj b/test/metabase/api/pulse_test.clj index ecf49c0af9f0d35ddb34050b2736d29b1874b82c..917cc830d1473c2e20a07a85851914b137855e14 100644 --- a/test/metabase/api/pulse_test.clj +++ b/test/metabase/api/pulse_test.clj @@ -43,7 +43,7 @@ (update :collection_id boolean) ;; why? these fields in this last assoc are from the PulseCard model and this function takes the Card model ;; because PulseCard is somewhat hidden behind the scenes - (assoc :include_csv false, :include_xls false, :dashboard_card_id nil, :dashboard_id nil, + (assoc :include_csv false, :include_xls false, :dashboard_card_id nil, :dashboard_id nil, :format_rows true :parameter_mappings nil))) (defn- pulse-channel-details [channel] @@ -329,10 +329,12 @@ :cards [{:id (u/the-id card-1) :include_csv true :include_xls true + :format_rows true :dashboard_card_id nil} {:id (u/the-id card-2) :include_csv false :include_xls false + :format_rows true :dashboard_card_id nil}] :channels [daily-email-channel] :skip_if_empty false}) diff --git a/test/metabase/dashboard_subscription_test.clj b/test/metabase/dashboard_subscription_test.clj index 7aa0e6a50c6d0bb5c553acccd35a487ae9c0f224..73a889e5acf0ce7541bf85c6c093c047e58ea6b6 100644 --- a/test/metabase/dashboard_subscription_test.clj +++ b/test/metabase/dashboard_subscription_test.clj @@ -891,7 +891,7 @@ (when (seq rows) [(let [^java.io.ByteArrayOutputStream baos (java.io.ByteArrayOutputStream.)] (with-open [os baos] - (#'messages/stream-api-results-to-export-format :csv os result) + (#'messages/stream-api-results-to-export-format :csv true os result) (let [output-string (.toString baos "UTF-8")] {:type :attachment :content-type :csv diff --git a/test/metabase/models/pulse_test.clj b/test/metabase/models/pulse_test.clj index 3ac74599963e1a74330c66ce9787f5a0680d984a..dbcf503eafeaa3c6d571264207480eea61789ad6 100644 --- a/test/metabase/models/pulse_test.clj +++ b/test/metabase/models/pulse_test.clj @@ -80,6 +80,7 @@ :display :table :include_csv false :include_xls false + :format_rows true :dashboard_card_id nil :dashboard_id nil :parameter_mappings nil}] @@ -166,6 +167,7 @@ :display :table :include_csv false :include_xls false + :format_rows true :dashboard_card_id nil :dashboard_id nil :parameter_mappings nil}]}) @@ -256,6 +258,7 @@ :display :bar :include_csv false :include_xls false + :format_rows true :dashboard_card_id nil :dashboard_id nil :parameter_mappings nil} @@ -265,6 +268,7 @@ :display :table :include_csv false :include_xls false + :format_rows true :dashboard_card_id nil :dashboard_id nil :parameter_mappings nil}] diff --git a/test/metabase/query_processor/streaming/xlsx_test.clj b/test/metabase/query_processor/streaming/xlsx_test.clj index 1f1584ef8dcbb50ebf0e7b2fd27b63d470ae02b9..ede84ea189ff7429e2cf23eb4b03df47bc7b06bc 100644 --- a/test/metabase/query_processor/streaming/xlsx_test.clj +++ b/test/metabase/query_processor/streaming/xlsx_test.clj @@ -579,17 +579,16 @@ (testing "LocalDateTime formatted as a string; should be parsed when *parse-temporal-string-values* is true" (is (= ["2020-03-28T10:12:06.681"] (second (xlsx-export [{:id 0, :name "Col"}] {} [["2020-03-28T10:12:06.681"]])))) - (binding [qp.xlsx/*parse-temporal-string-values* true] - (is (= [#inst "2020-03-28T10:12:06.681"] - (second (xlsx-export [{:id 0, :name "Col" :effective_type :type/Temporal}] - {} - [["2020-03-28T10:12:06.681"]])))) - (testing "Values that are parseable as dates are not when effective_type is not temporal (#29708)" - (doseq [value ["0001" "4161" "02" "2020-03-28T10:12:06.681"]] - (is (= [value] - (second (xlsx-export [{:id 0, :name "Col" :effective_type :type/Text}] - {} - [[value]])))))))) + (is (= [#inst "2020-03-28T10:12:06.681"] + (second (xlsx-export [{:id 0, :name "Col" :effective_type :type/Temporal}] + {} + [["2020-03-28T10:12:06.681"]])))) + (testing "Values that are parseable as dates are not when effective_type is not temporal (#29708)" + (doseq [value ["0001" "4161" "02" "2020-03-28T10:12:06.681"]] + (is (= [value] + (second (xlsx-export [{:id 0, :name "Col" :effective_type :type/Text}] + {} + [[value]]))))))) (mt/with-metadata-provider (mt/id) (binding [driver/*driver* :h2] (testing "OffsetDateTime" @@ -602,11 +601,10 @@ (is (= [#inst "2020-03-28T10:12:06.000-00:00"] (second (xlsx-export [{:id 0, :name "Col"}] {} [[#t "2020-03-28T10:12:06Z"]]))))))) (testing "Strings representing country names/codes don't error when *parse-temporal-string-values* is true (#18724)" - (binding [qp.xlsx/*parse-temporal-string-values* true] - (is (= ["GB"] - (second (xlsx-export [{:id 0, :name "Col"}] {} [["GB"]])))) - (is (= ["Portugal"] - (second (xlsx-export [{:id 0, :name "Col"}] {} [["Portugal"]])))))) + (is (= ["GB"] + (second (xlsx-export [{:id 0, :name "Col"}] {} [["GB"]])))) + (is (= ["Portugal"] + (second (xlsx-export [{:id 0, :name "Col"}] {} [["Portugal"]]))))) (testing "NaN and infinity values (#21343)" ;; These values apparently are represented as error codes, which are parsed here into keywords (is (= [:NUM]