Skip to content

Commit

Permalink
feat(vision): add "save result as json/csv" buttons (#6158)
Browse files Browse the repository at this point in the history
* feat(vision): add download as json/csv buttons

* fix(vision): use blob urls for downloads (#6213)

* fix(vision): use Translate component to avoid splitting i18n strings

* fix(vision): clean up i18n resources for result saving feature

---------

Co-authored-by: Espen Hovlandsdal <[email protected]>
  • Loading branch information
2 people authored and ricokahler committed May 14, 2024
1 parent c2b143e commit 14be9b6
Show file tree
Hide file tree
Showing 7 changed files with 173 additions and 6 deletions.
1 change: 1 addition & 0 deletions packages/@sanity/vision/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"@sanity/ui": "^2.1.6",
"@uiw/react-codemirror": "^4.11.4",
"is-hotkey-esm": "^1.0.0",
"json-2-csv": "^5.5.1",
"json5": "^2.2.3",
"lodash": "^4.17.21",
"quick-lru": "^5.1.1"
Expand Down
57 changes: 57 additions & 0 deletions packages/@sanity/vision/src/components/SaveResultButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {DocumentSheetIcon} from '@sanity/icons'
import {Button, Tooltip} from '@sanity/ui'
import {type MouseEvent} from 'react'
import {useTranslation} from 'sanity'

import {visionLocaleNamespace} from '../i18n'

interface SaveButtonProps {
blobUrl: string | undefined
}

function preventSave(evt: MouseEvent<HTMLButtonElement>) {
return evt.preventDefault()
}

export function SaveCsvButton({blobUrl}: SaveButtonProps) {
const {t} = useTranslation(visionLocaleNamespace)
const isDisabled = !blobUrl

const button = (
<Button
as="a"
disabled={isDisabled}
download={isDisabled ? undefined : 'query-result.csv'}
href={blobUrl}
icon={DocumentSheetIcon}
mode="ghost"
onClick={isDisabled ? preventSave : undefined}
// eslint-disable-next-line @sanity/i18n/no-attribute-string-literals
text="CSV" // String is a File extension
tone="default"
/>
)

return isDisabled ? (
<Tooltip content={t('result.save-result-as-csv.not-csv-encodable')} placement="top">
{button}
</Tooltip>
) : (
button
)
}

export function SaveJsonButton({blobUrl}: SaveButtonProps) {
return (
<Button
as="a"
download={'query-result.json'}
href={blobUrl}
icon={DocumentSheetIcon}
mode="ghost"
// eslint-disable-next-line @sanity/i18n/no-attribute-string-literals
text="JSON" // String is a File extension
tone="default"
/>
)
}
21 changes: 19 additions & 2 deletions packages/@sanity/vision/src/components/VisionGui.styled.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Box, Card, Flex, Label, rem} from '@sanity/ui'
import {Box, Card, Flex, Label, rem, Text} from '@sanity/ui'
import {css, styled} from 'styled-components'

export const Root = styled(Flex)`
Expand Down Expand Up @@ -127,7 +127,7 @@ export const Result = styled(Box)`
z-index: 20;
`

export const TimingsFooter = styled(Box)`
export const ResultFooter = styled(Flex)`
border-top: 1px solid var(--card-border-color);
`

Expand All @@ -151,6 +151,23 @@ export const TimingsTextContainer = styled(Flex)`
)};
`

export const DownloadsCard = styled(Card)`
position: relative;
`

export const SaveResultLabel = styled(Text)`
transform: initial;
&:before,
&:after {
content: none;
}
> span {
display: flex !important;
gap: ${({theme}) => rem(theme.sanity.space[3])};
align-items: center;
}
`

export const ControlsContainer = styled(Box)`
border-top: 1px solid var(--card-border-color);
`
33 changes: 29 additions & 4 deletions packages/@sanity/vision/src/components/VisionGui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@ import {
} from '@sanity/ui'
import {isHotkey} from 'is-hotkey-esm'
import {type ChangeEvent, createRef, PureComponent, type RefObject} from 'react'
import {type TFunction} from 'sanity'
import {type TFunction, Translate} from 'sanity'

import {API_VERSIONS, DEFAULT_API_VERSION} from '../apiVersions'
import {VisionCodeMirror} from '../codemirror/VisionCodeMirror'
import {DEFAULT_PERSPECTIVE, isPerspective, PERSPECTIVES} from '../perspectives'
import {type VisionProps} from '../types'
import {encodeQueryString} from '../util/encodeQueryString'
import {getCsvBlobUrl, getJsonBlobUrl} from '../util/getBlobUrl'
import {getLocalStorage, type LocalStorageish} from '../util/localStorage'
import {parseApiQueryString, type ParsedApiQueryString} from '../util/parseApiQueryString'
import {prefixApiVersion} from '../util/prefixApiVersion'
Expand All @@ -42,22 +43,25 @@ import {ParamsEditor, type ParamsEditorChangeEvent} from './ParamsEditor'
import {PerspectivePopover} from './PerspectivePopover'
import {QueryErrorDialog} from './QueryErrorDialog'
import {ResultView} from './ResultView'
import {SaveCsvButton, SaveJsonButton} from './SaveResultButtons'
import {
ControlsContainer,
DownloadsCard,
Header,
InputBackgroundContainer,
InputBackgroundContainerLeft,
InputContainer,
QueryCopyLink,
Result,
ResultContainer,
ResultFooter,
ResultInnerContainer,
ResultOuterContainer,
Root,
SaveResultLabel,
SplitpaneContainer,
StyledLabel,
TimingsCard,
TimingsFooter,
TimingsTextContainer,
} from './VisionGui.styled'

Expand Down Expand Up @@ -669,6 +673,8 @@ export class VisionGui extends PureComponent<VisionGuiProps, VisionGuiState> {
url,
} = this.state
const hasResult = !error && !queryInProgress && typeof queryResult !== 'undefined'
const jsonUrl = hasResult ? getJsonBlobUrl(queryResult) : ''
const csvUrl = hasResult ? getCsvBlobUrl(queryResult) : ''

return (
<Root
Expand Down Expand Up @@ -948,7 +954,7 @@ export class VisionGui extends PureComponent<VisionGuiProps, VisionGuiState> {
</ResultContainer>
</ResultInnerContainer>
{/* Execution time */}
<TimingsFooter>
<ResultFooter justify="space-between" direction={['column', 'column', 'row']}>
<TimingsCard paddingX={4} paddingY={3} sizing="border">
<TimingsTextContainer align="center">
<Box>
Expand All @@ -969,7 +975,26 @@ export class VisionGui extends PureComponent<VisionGuiProps, VisionGuiState> {
</Box>
</TimingsTextContainer>
</TimingsCard>
</TimingsFooter>

{hasResult && (
<DownloadsCard paddingX={4} paddingY={3} sizing="border">
<SaveResultLabel muted>
<Translate
components={{
SaveResultButtons: () => (
<>
<SaveJsonButton blobUrl={jsonUrl} />
<SaveCsvButton blobUrl={csvUrl} />
</>
),
}}
i18nKey="result.save-result-as-format"
t={t}
/>
</SaveResultLabel>
</DownloadsCard>
)}
</ResultFooter>
</ResultOuterContainer>
</SplitPane>
</SplitpaneContainer>
Expand Down
4 changes: 4 additions & 0 deletions packages/@sanity/vision/src/i18n/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ const visionLocaleStrings = defineLocalesResources('vision', {
'result.execution-time-label': 'Execution',
/** Label for "Result" explorer/view */
'result.label': 'Result',
/** Tooltip text shown when the query result is not encodable as CSV */
'result.save-result-as-csv.not-csv-encodable': 'Result cannot be encoded as CSV',
/** Label for "Save result as" result action */
'result.save-result-as-format': 'Save result as <SaveResultButtons/>',
/**
* "Not applicable" message for when there is no Execution time or End to End time information
* available for the query (eg when the query has not been executed, or errored)
Expand Down
42 changes: 42 additions & 0 deletions packages/@sanity/vision/src/util/getBlobUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {json2csv} from 'json-2-csv'

function getBlobUrl(content: string, mimeType: string): string {
return URL.createObjectURL(
new Blob([content], {
type: mimeType,
}),
)
}

function getMemoizedBlobUrlResolver(mimeType: string, stringEncoder: (input: any) => string) {
return (() => {
let prevResult = ''
let prevContent = ''
return (input: unknown) => {
const content = stringEncoder(input)
if (typeof content !== 'string' || content === '') {
return undefined
}

if (content === prevContent) {
return prevResult
}

prevContent = content
if (prevResult) {
URL.revokeObjectURL(prevResult)
}

prevResult = getBlobUrl(content, mimeType)
return prevResult
}
})()
}

export const getJsonBlobUrl = getMemoizedBlobUrlResolver('application/json', (input) =>
JSON.stringify(input, null, 2),
)

export const getCsvBlobUrl = getMemoizedBlobUrlResolver('text/csv', (input) => {
return json2csv(Array.isArray(input) ? input : [input]).trim()
})
21 changes: 21 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 14be9b6

Please sign in to comment.