From 093ced439d91d597a359205d624974977b5db19e Mon Sep 17 00:00:00 2001 From: Alexei Mochalov Date: Fri, 3 Sep 2021 13:35:27 +0500 Subject: [PATCH] Fix preview rendering inside packages, refactor logic related to view modes (#2328) * fix max update depth exceeded while rendering previews inside packages * Preview/Display: support executing effects when preview data changes * SelectDropdown: support passing props to the root element, export option type * refactor ViewModes-related logic * ResourceCache: support persistent resources * utils/voila: determine voila availability (cached) * rm utils/global * remove fetch-mock package * mock for utils/voila --- catalog/app/components/Preview/Display.js | 14 +- .../SelectDropdown/SelectDropdown.tsx | 9 +- .../app/components/SelectDropdown/index.ts | 1 + catalog/app/containers/Bucket/File.js | 54 ++-- catalog/app/containers/Bucket/FileView.js | 13 +- catalog/app/containers/Bucket/PackageTree.js | 80 +++--- .../app/containers/Bucket/renderPreview.js | 2 +- .../app/containers/Bucket/viewModes.spec.ts | 254 ++++++++++-------- catalog/app/containers/Bucket/viewModes.ts | 136 ++++------ catalog/app/embed/File.js | 2 +- catalog/app/utils/ResourceCache.js | 9 +- catalog/app/utils/__mocks__/voila.ts | 16 ++ catalog/app/utils/global.ts | 16 -- catalog/app/utils/voila.ts | 28 ++ catalog/package-lock.json | 161 ----------- catalog/package.json | 1 - docs/CHANGELOG.md | 1 + 17 files changed, 316 insertions(+), 481 deletions(-) create mode 100644 catalog/app/utils/__mocks__/voila.ts delete mode 100644 catalog/app/utils/global.ts create mode 100644 catalog/app/utils/voila.ts diff --git a/catalog/app/components/Preview/Display.js b/catalog/app/components/Preview/Display.js index 9ce47ca597e..045a758d9c0 100644 --- a/catalog/app/components/Preview/Display.js +++ b/catalog/app/components/Preview/Display.js @@ -6,7 +6,6 @@ import * as AWS from 'utils/AWS' import AsyncResult from 'utils/AsyncResult' import * as Config from 'utils/Config' import StyledLink from 'utils/StyledLink' -import pipeThru from 'utils/pipeThru' import render from './render' import { PreviewError } from './types' @@ -42,11 +41,17 @@ export default function PreviewDisplay({ renderProgress = defaultProgress, renderMessage = defaultMessage, renderAction = defaultAction, + onData, }) { const cfg = Config.use() const noDl = noDownload != null ? noDownload : cfg.noDownload - return pipeThru(data)( - AsyncResult.case({ + + React.useEffect(() => { + onData?.(data) + }, [data, onData]) + + return AsyncResult.case( + { _: renderProgress, Ok: R.pipe(render, renderContents), Err: PreviewError.case({ @@ -133,7 +138,8 @@ export default function PreviewDisplay({ action: !!retry && renderAction({ label: 'Retry', onClick: retry }), }), }), - }), + }, + data, ) } diff --git a/catalog/app/components/SelectDropdown/SelectDropdown.tsx b/catalog/app/components/SelectDropdown/SelectDropdown.tsx index a4f32242012..b2e12381fd0 100644 --- a/catalog/app/components/SelectDropdown/SelectDropdown.tsx +++ b/catalog/app/components/SelectDropdown/SelectDropdown.tsx @@ -1,3 +1,4 @@ +import cx from 'classnames' import * as React from 'react' import * as M from '@material-ui/core' @@ -19,7 +20,7 @@ const useStyles = M.makeStyles((t) => ({ }, })) -interface ValueBase { +export interface ValueBase { toString: () => string valueOf: () => string | number | boolean } @@ -36,7 +37,9 @@ export default function SelectDropdown({ onChange, options, value, -}: SelectDropdownProps) { + className, + ...props +}: SelectDropdownProps & M.PaperProps) { const classes = useStyles() const [anchorEl, setAnchorEl] = React.useState(null) @@ -57,7 +60,7 @@ export default function SelectDropdown({ const aboveSm = M.useMediaQuery(t.breakpoints.up('sm')) return ( - + R.chain( @@ -325,7 +325,7 @@ export default function File({ }, location, }) { - const { version, mode: viewModeSlug } = parseSearch(location.search) + const { version, mode } = parseSearch(location.search) const classes = useStyles() const { urls } = NamedRoutes.use() const history = useHistory() @@ -382,9 +382,18 @@ export default function File({ }), }) + const viewModes = useViewModes(path, mode) + + const onViewModeChange = React.useCallback( + (m) => { + history.push(urls.bucketFile(bucket, encodedPath, version, m.valueOf())) + }, + [history, urls, bucket, encodedPath, version], + ) + const handle = { bucket, key: path, version } - const withPreview = (callback, mode) => + const withPreview = (callback) => requests.ObjectExistence.case({ Exists: (h) => { if (h.deleted) { @@ -393,39 +402,12 @@ export default function File({ if (h.archived) { return callback(AsyncResult.Err(Preview.PreviewError.Archived({ handle }))) } - return mode - ? Preview.load(R.assoc('mode', mode.key, handle), callback) - : Preview.load(handle, callback) + return Preview.load({ ...handle, mode: viewModes.mode }, callback) }, DoesNotExist: () => callback(AsyncResult.Err(Preview.PreviewError.InvalidVersion({ handle }))), }) - const [previewResult, setPreviewResult] = React.useState(false) - const onRender = React.useCallback( - (result) => { - if (AsyncResult.Ok.is(result) && !previewResult) { - setPreviewResult(result) - return renderPreview(AsyncResult.Pending()) - } - return renderPreview(result) - }, - [previewResult, setPreviewResult], - ) - - const { registryUrl } = Config.use() - const viewModes = useViewModes(registryUrl, path, previewResult) - const viewMode = React.useMemo( - () => viewModes.find(({ key }) => key === viewModeSlug) || viewModes[0] || null, - [viewModes, viewModeSlug], - ) - const onViewModeChange = React.useCallback( - (mode) => { - history.push(urls.bucketFile(bucket, encodedPath, version, mode.key)) - }, - [history, urls, bucket, encodedPath, version], - ) - return ( {[path || 'Files', bucket]} @@ -449,11 +431,11 @@ export default function File({
- {!!viewModes.length && ( - )} @@ -487,7 +469,7 @@ export default function File({ Err: (e) => { throw e }, - Ok: withPreview(onRender, viewMode), + Ok: withPreview(renderPreview(viewModes.handlePreviewResult)), })} diff --git a/catalog/app/containers/Bucket/FileView.js b/catalog/app/containers/Bucket/FileView.js index c02ebd611e3..e2a1ce8aa28 100644 --- a/catalog/app/containers/Bucket/FileView.js +++ b/catalog/app/containers/Bucket/FileView.js @@ -79,19 +79,12 @@ export function DownloadButton({ className, handle }) { )) } -const viewModeToSelectOption = ({ key, label }) => ({ - key, - toString: () => label, - valueOf: () => key, -}) - -export function ViewWithVoilaButtonLayout({ modesList, mode, ...props }) { +export function ViewModeSelector({ className, ...props }) { + const classes = useDownloadButtonStyles() const t = M.useTheme() const sm = M.useMediaQuery(t.breakpoints.down('sm')) - const options = React.useMemo(() => modesList.map(viewModeToSelectOption), [modesList]) - const value = React.useMemo(() => viewModeToSelectOption(mode), [mode]) return ( - + {sm ? visibility : 'View as:'} ) diff --git a/catalog/app/containers/Bucket/PackageTree.js b/catalog/app/containers/Bucket/PackageTree.js index d46fa7bd56c..4357daff7d6 100644 --- a/catalog/app/containers/Bucket/PackageTree.js +++ b/catalog/app/containers/Bucket/PackageTree.js @@ -39,7 +39,7 @@ import Summary from './Summary' import * as errors from './errors' import renderPreview from './renderPreview' import * as requests from './requests' -import useViewModes from './viewModes' +import { useViewModes, viewModeToSelectOption } from './viewModes' /* function ExposeLinkedData({ bucketCfg, bucket, name, hash, modified }) { @@ -395,11 +395,12 @@ const useFileDisplayStyles = M.makeStyles((t) => ({ }, })) -function FileDisplay({ bucket, mode: modeSlug, name, hash, revision, path, crumbs }) { +function FileDisplay({ bucket, mode, name, hash, revision, path, crumbs }) { const s3 = AWS.S3.use() const credentials = AWS.Credentials.use() const { apiGatewayEndpoint: endpoint, noDownload } = Config.use() - + const history = useHistory() + const { urls } = NamedRoutes.use() const classes = useFileDisplayStyles() const data = useData(requests.packageFileDetail, { @@ -412,6 +413,25 @@ function FileDisplay({ bucket, mode: modeSlug, name, hash, revision, path, crumb path, }) + const viewModes = useViewModes(path, mode) + + const onViewModeChange = React.useCallback( + (m) => { + history.push(urls.bucketPackageTree(bucket, name, revision, path, m.valueOf())) + }, + [bucket, history, name, path, revision, urls], + ) + + const withPreview = ({ archived, deleted, handle }, callback) => { + if (deleted) { + return callback(AsyncResult.Err(Preview.PreviewError.Deleted({ handle }))) + } + if (archived) { + return callback(AsyncResult.Err(Preview.PreviewError.Archived({ handle }))) + } + return Preview.load({ ...handle, mode: viewModes.mode }, callback) + } + const renderProgress = () => ( // TODO: skeleton placeholder <> @@ -438,47 +458,6 @@ function FileDisplay({ bucket, mode: modeSlug, name, hash, revision, path, crumb ) - const withPreview = ({ archived, deleted, handle, mode }, callback) => { - if (deleted) { - return callback(AsyncResult.Err(Preview.PreviewError.Deleted({ handle }))) - } - if (archived) { - return callback(AsyncResult.Err(Preview.PreviewError.Archived({ handle }))) - } - return mode - ? Preview.load(R.assoc('mode', mode.key, handle), callback) - : Preview.load(handle, callback) - } - - const history = useHistory() - const { urls } = NamedRoutes.use() - - const [previewResult, setPreviewResult] = React.useState(false) - const onRender = React.useCallback( - (result) => { - if (previewResult === result) { - return renderPreview(result) - } - - setPreviewResult(result) - return renderPreview(AsyncResult.Pending()) - }, - [previewResult, setPreviewResult], - ) - - const { registryUrl } = Config.use() - const viewModes = useViewModes(registryUrl, path, previewResult) - const viewMode = React.useMemo( - () => viewModes.find(({ key }) => key === modeSlug) || viewModes[0] || null, - [viewModes, modeSlug], - ) - const onViewModeChange = React.useCallback( - (mode) => { - history.push(urls.bucketPackageTree(bucket, name, revision, path, mode.key)) - }, - [bucket, history, name, path, revision, urls], - ) - return data.case({ Ok: ({ meta, ...handle }) => ( @@ -496,11 +475,11 @@ function FileDisplay({ bucket, mode: modeSlug, name, hash, revision, path, crumb Exists: ({ archived, deleted }) => ( <> - {!!viewModes.length && ( - )} @@ -511,7 +490,10 @@ function FileDisplay({ bucket, mode: modeSlug, name, hash, revision, path, crumb
- {withPreview({ archived, deleted, handle, mode: viewMode }, onRender)} + {withPreview( + { archived, deleted, handle }, + renderPreview(viewModes.handlePreviewResult), + )}
), diff --git a/catalog/app/containers/Bucket/renderPreview.js b/catalog/app/containers/Bucket/renderPreview.js index fe18994fb5d..9ca2cba4cf6 100644 --- a/catalog/app/containers/Bucket/renderPreview.js +++ b/catalog/app/containers/Bucket/renderPreview.js @@ -30,4 +30,4 @@ const renderProgress = () => ( ) -export default Preview.display({ renderMessage, renderProgress }) +export default (onData) => Preview.display({ renderMessage, renderProgress, onData }) diff --git a/catalog/app/containers/Bucket/viewModes.spec.ts b/catalog/app/containers/Bucket/viewModes.spec.ts index 6439065d05e..4b7a5ff7fc4 100644 --- a/catalog/app/containers/Bucket/viewModes.spec.ts +++ b/catalog/app/containers/Bucket/viewModes.spec.ts @@ -1,136 +1,164 @@ /** * @jest-environment jsdom */ -import { mocked } from 'ts-jest/utils' -import { renderHook } from '@testing-library/react-hooks' + +import { renderHook, act } from '@testing-library/react-hooks' // NOTE: module imported selectively because Preview's deps break unit-tests import { PreviewData } from 'components/Preview/types' import AsyncResult from 'utils/AsyncResult' -import global from 'utils/global' - -import useViewModes from './viewModes' - -jest.mock('utils/global') - -function fetchOk(): Promise { - return new Promise((resolve) => - setTimeout(() => { - resolve({ - ok: true, - } as Response) - }, 100), - ) -} - -function fetchNotOk(): Promise { - return new Promise((resolve) => - setTimeout(() => { - resolve({ - ok: false, - } as Response) - }, 100), - ) -} - -const jsonResult = AsyncResult.Ok( - PreviewData.Json({ - rendered: { - $schema: 'https://vega.github.io/schema/a/b.json', - }, - }), -) - -const vegaResult = AsyncResult.Ok( - PreviewData.Vega({ - spec: { - $schema: 'https://vega.github.io/schema/a/b.json', - }, - }), -) - -const imgResult = AsyncResult.Ok(PreviewData.Image()) - -const pendingResult = AsyncResult.Pending() +import * as voila from 'utils/voila' -describe('containers/Bucket/viewModes', () => { - describe('useViewModes', () => { - afterEach(() => { - mocked(global.fetch).mockClear() - }) +import { useViewModes, viewModeToSelectOption } from './viewModes' - it('returns empty list when no modes', () => { - const { result } = renderHook(() => - useViewModes('https://registry.example', 'test.md'), - ) - expect(result.current).toMatchObject([]) - }) +jest.mock('utils/voila') - it('returns Notebooks modes for .ipynb when no Voila service', async () => { - mocked(global.fetch).mockImplementation(fetchNotOk) +const VEGA_SCHEMA = 'https://vega.github.io/schema/a/b.json' - const { result } = renderHook(() => - useViewModes('https://registry.example', 'test.ipynb'), - ) - expect(result.current).toMatchObject([ - { key: 'jupyter', label: 'Jupyter' }, - { key: 'json', label: 'JSON' }, - ]) - }) +const previewDataJsonPlain = PreviewData.Json({ rendered: { a: 1 } }) +const previewDataJsonVega = PreviewData.Json({ rendered: { $schema: VEGA_SCHEMA } }) +const previewDataVega = PreviewData.Vega({ spec: { $schema: VEGA_SCHEMA } }) - it('returns Notebooks modes for .ipynb with Voila mode', async () => { - mocked(global.fetch).mockImplementation(fetchOk) - - const { result, waitForNextUpdate } = renderHook(() => - useViewModes('https://registry.example', 'test.ipynb'), - ) - await waitForNextUpdate() - expect(result.current).toMatchObject([ - { key: 'jupyter', label: 'Jupyter' }, - { key: 'json', label: 'JSON' }, - { key: 'voila', label: 'Voila' }, - ]) - }) +const render = (...args: Parameters) => + renderHook(() => useViewModes(...args)) - it('returns no modes for .json when no Vega mode', async () => { - const { result } = renderHook(() => - useViewModes('https://registry.example', 'test.json'), - ) - expect(result.current).toMatchObject([]) +describe('containers/Bucket/viewModes', () => { + describe('viewModeToSelectOption', () => { + it('returns null when given null', () => { + expect(viewModeToSelectOption(null)).toBe(null) }) - - it('returns no modes for .json when result is pending', async () => { - const { result } = renderHook(() => - useViewModes('https://registry.example', 'test.json', pendingResult), - ) - expect(result.current).toMatchObject([]) + it('returns propery formatted select option when given a view mode', () => { + const opt = viewModeToSelectOption('json') + expect(opt.toString()).toBe('JSON') + expect(opt.valueOf()).toBe('json') }) + }) - it('returns Vega/JSON modes for .json when Vega mode selected', async () => { - const { result } = renderHook(() => - useViewModes('https://registry.example', 'test.json', vegaResult), - ) - expect(result.current).toMatchObject([ - { key: 'vega', label: 'Vega' }, - { key: 'json', label: 'JSON' }, - ]) + describe('useViewModes', () => { + describe('for files with no alternative view modes', () => { + const path = 'test.md' + + it('returns empty mode list and null mode when given no mode input', () => { + expect(render(path, null).result.current).toMatchObject({ modes: [], mode: null }) + }) + + it('returns empty mode list and null mode when given any mode input', () => { + expect(render(path, 'some-mode').result.current).toMatchObject({ + modes: [], + mode: null, + }) + }) }) - it('returns Vega/JSON modes for .json when JSON mode selected', async () => { - const { result } = renderHook(() => - useViewModes('https://registry.example', 'test.json', jsonResult), - ) - expect(result.current).toMatchObject([ - { key: 'vega', label: 'Vega' }, - { key: 'json', label: 'JSON' }, - ]) + describe('for Jupyter notebook files', () => { + const path = 'test.ipynb' + + describe('when Voila is available', () => { + beforeEach(() => { + ;(voila as any).override(true) + }) + + afterEach(() => { + ;(voila as any).reset() + }) + + it('returns Jupyter, JSON and Voila modes and defaults to Jupyter mode when no mode is given', () => { + expect(render(path, null).result.current).toMatchObject({ + modes: ['jupyter', 'json', 'voila'], + mode: 'jupyter', + }) + }) + + it('returns Jupyter, JSON and Voila modes and selected mode when correct mode is given', () => { + expect(render(path, 'voila').result.current).toMatchObject({ + modes: ['jupyter', 'json', 'voila'], + mode: 'voila', + }) + }) + + it('returns Jupyter, JSON and Voila modes and defaults to Jupyter mode when incorrect mode is given', () => { + expect(render(path, 'bad').result.current).toMatchObject({ + modes: ['jupyter', 'json', 'voila'], + mode: 'jupyter', + }) + }) + }) + + describe('when Voila is unavailable', () => { + it('returns Jupyter and JSON modes and defaults to Jupyter mode when no mode is given', () => { + expect(render(path, null).result.current).toMatchObject({ + modes: ['jupyter', 'json'], + mode: 'jupyter', + }) + }) + }) }) - it('returns no modes for .json when result is different Preview', async () => { - const { result } = renderHook(() => - useViewModes('https://registry.example', 'test.json', imgResult), - ) - expect(result.current).toMatchObject([]) + describe('for JSON files', () => { + const path = 'test.json' + + it('initially returns empty mode list and null mode when given any mode', () => { + expect(render(path, 'vega').result.current).toMatchObject({ + modes: [], + mode: null, + }) + }) + + it('ignores non-Ok results', () => { + const { result } = render(path, null) + act(() => { + result.current.handlePreviewResult(AsyncResult.Pending()) + }) + expect(result.current).toMatchObject({ modes: [], mode: null }) + act(() => { + result.current.handlePreviewResult(AsyncResult.Err()) + }) + expect(result.current).toMatchObject({ modes: [], mode: null }) + }) + + it('only sets result once', () => { + const { result } = render(path, null) + act(() => { + result.current.handlePreviewResult(AsyncResult.Ok(previewDataJsonVega)) + }) + expect(result.current).toMatchObject({ modes: ['vega', 'json'], mode: 'vega' }) + act(() => { + result.current.handlePreviewResult(AsyncResult.Ok(previewDataJsonPlain)) + }) + expect(result.current).toMatchObject({ modes: ['vega', 'json'], mode: 'vega' }) + }) + + it('returns Vega and JSON modes and defaults to Vega mode for Vega preview data', () => { + const { result } = render(path, null) + act(() => { + result.current.handlePreviewResult(AsyncResult.Ok(previewDataVega)) + }) + expect(result.current).toMatchObject({ modes: ['vega', 'json'], mode: 'vega' }) + }) + + it('returns Vega and JSON modes and defaults to Vega mode for JSON preview data with vega schema', () => { + const { result } = render(path, null) + act(() => { + result.current.handlePreviewResult(AsyncResult.Ok(previewDataJsonVega)) + }) + expect(result.current).toMatchObject({ modes: ['vega', 'json'], mode: 'vega' }) + }) + + it('returns empty mode list and null mode for JSON preview data without vega schema', () => { + const { result } = render(path, null) + act(() => { + result.current.handlePreviewResult(AsyncResult.Ok(previewDataJsonPlain)) + }) + expect(result.current).toMatchObject({ modes: [], mode: null }) + }) + + it('returns empty mode list and null mode for other preview data', () => { + const { result } = render(path, null) + act(() => { + result.current.handlePreviewResult(AsyncResult.Ok(PreviewData.Image())) + }) + expect(result.current).toMatchObject({ modes: [], mode: null }) + }) }) }) }) diff --git a/catalog/app/containers/Bucket/viewModes.ts b/catalog/app/containers/Bucket/viewModes.ts index cd2f3ff9578..02b80b28629 100644 --- a/catalog/app/containers/Bucket/viewModes.ts +++ b/catalog/app/containers/Bucket/viewModes.ts @@ -1,106 +1,74 @@ import { extname } from 'path' -import * as R from 'ramda' import * as React from 'react' // NOTE: module imported selectively because Preview's deps break unit-tests import { PreviewData } from 'components/Preview/types' +import type { ValueBase as SelectOption } from 'components/SelectDropdown' import AsyncResult from 'utils/AsyncResult' -import global from 'utils/global' +import { useVoila } from 'utils/voila' -const VOILA_PING_URL = (registryUrl: string) => `${registryUrl}/voila/` - -async function pingVoilaService(registryUrl: string): Promise { - try { - const result = await global.fetch(VOILA_PING_URL(registryUrl)) - return result.ok - } catch (error) { - return false - } -} - -export interface ViewMode { - key: string - label: string +const MODES = { + json: 'JSON', + jupyter: 'Jupyter', + vega: 'Vega', + voila: 'Voila', } -const JSON_MODE = { key: 'json', label: 'JSON' } - -const JUPYTER_MODE = { key: 'jupyter', label: 'Jupyter' } - -const VEGA_MODE = { key: 'vega', label: 'Vega' } - -const VOILA_MODE = { key: 'voila', label: 'Voila' } +export type ViewMode = keyof typeof MODES const isVegaSchema = (schema: string) => { if (!schema) return false return !!schema.match(/https:\/\/vega\.github\.io\/schema\/([\w-]+)\/([\w.-]+)\.json/) } -export default function useViewModes( - registryUrl: string, - path: string, - previewResult?: $TSFixMe, -): ViewMode[] { - const [viewModes, setViewModes] = React.useState([]) - - const handleNotebook = React.useCallback(async () => { - setViewModes([JUPYTER_MODE, JSON_MODE]) - const isVoilaSupported = await pingVoilaService(registryUrl) - if (isVoilaSupported) { - setViewModes(R.append(VOILA_MODE)) - } else { - // eslint-disable-next-line no-console - console.debug('Voila is not supported by current stack') - // TODO: add link to documentation +export function viewModeToSelectOption(m: ViewMode): SelectOption +export function viewModeToSelectOption(m: null): null +export function viewModeToSelectOption(m: ViewMode | null): SelectOption | null { + return ( + m && { + toString: () => MODES[m], + valueOf: () => m, } - }, [registryUrl]) - - const handleJson = React.useCallback(() => { - if (!previewResult) return + ) +} - AsyncResult.case( - { - Ok: (jsonResult: $TSFixMe) => { - PreviewData.case( - { - Vega: (json: any) => { - if (isVegaSchema(json.spec?.$schema)) { - setViewModes([VEGA_MODE, JSON_MODE]) - } - }, - Json: (json: any) => { - if (isVegaSchema(json.rendered?.$schema)) { - setViewModes([VEGA_MODE, JSON_MODE]) - } - }, - _: () => null, - }, - jsonResult, - ) - }, - _: () => null, - }, - previewResult, - ) - }, [previewResult]) +export function useViewModes(path: string, modeInput: string | null | undefined) { + const voilaAvailable = useVoila() + const [previewResult, setPreviewResult] = React.useState(null) - React.useEffect(() => { - async function fillViewModes() { - const ext = extname(path) - switch (ext) { - case '.ipynb': { - handleNotebook() - break - } - case '.json': { - handleJson() - break - } - // no default + const handlePreviewResult = React.useCallback( + (result) => { + if (!previewResult && AsyncResult.Ok.is(result)) { + setPreviewResult(AsyncResult.Ok.unbox(result)) } + }, + [previewResult, setPreviewResult], + ) + + const modes: ViewMode[] = React.useMemo(() => { + switch (extname(path)) { + case '.ipynb': + return voilaAvailable ? ['jupyter', 'json', 'voila'] : ['jupyter', 'json'] + case '.json': + return PreviewData.case( + { + Vega: (json: any) => + isVegaSchema(json.spec?.$schema) ? ['vega', 'json'] : [], + Json: (json: any) => + isVegaSchema(json.rendered?.$schema) ? ['vega', 'json'] : [], + _: () => [], + __: () => [], + }, + previewResult, + ) + default: + return [] } - fillViewModes() - }, [handleJson, handleNotebook, path, registryUrl]) + }, [path, previewResult, voilaAvailable]) + + const mode = ( + modes.includes(modeInput as any) ? modeInput : modes[0] || null + ) as ViewMode | null - return viewModes + return { modes, mode, handlePreviewResult } } diff --git a/catalog/app/embed/File.js b/catalog/app/embed/File.js index 68593f9ae2d..fa107dd3f08 100644 --- a/catalog/app/embed/File.js +++ b/catalog/app/embed/File.js @@ -476,7 +476,7 @@ export default function File({ Err: (e) => { throw e }, - Ok: withPreview(renderPreview), + Ok: withPreview(renderPreview()), })} diff --git a/catalog/app/utils/ResourceCache.js b/catalog/app/utils/ResourceCache.js index ff4d149059b..9f783261177 100644 --- a/catalog/app/utils/ResourceCache.js +++ b/catalog/app/utils/ResourceCache.js @@ -39,11 +39,12 @@ const RELEASE_TIME = 5000 // } // State = Map>> -export const createResource = ({ name, fetch, key = R.identity }) => ({ +export const createResource = ({ name, fetch, key = R.identity, persist = false }) => ({ name, fetch, id: uuid.v4(), key, + persist, }) const Action = tagged([ @@ -66,7 +67,11 @@ const reducer = reduxTools.withInitialState( (s) => s.updateIn(keyFor(resource, input), (entry) => { if (entry) throw new Error('Init: entry already exists') - return { promise, result: AsyncResult.Init(), claimed: 0 } + return { + promise, + result: AsyncResult.Init(), + claimed: resource.persist ? 1 : 0, // "persistent" resources won't be released + } }), Request: ({ resource, input }) => diff --git a/catalog/app/utils/__mocks__/voila.ts b/catalog/app/utils/__mocks__/voila.ts new file mode 100644 index 00000000000..3a4efcffd2a --- /dev/null +++ b/catalog/app/utils/__mocks__/voila.ts @@ -0,0 +1,16 @@ +const defaultResult = false +let overrideResult: boolean | null = null + +export function override(value: boolean) { + overrideResult = value +} + +export function reset() { + overrideResult = null +} + +export function useVoila() { + return overrideResult ?? defaultResult +} + +export { useVoila as use } diff --git a/catalog/app/utils/global.ts b/catalog/app/utils/global.ts deleted file mode 100644 index 167acad2f8f..00000000000 --- a/catalog/app/utils/global.ts +++ /dev/null @@ -1,16 +0,0 @@ -const Global = { - fetch: window?.fetch - ? window.fetch.bind(window) - : () => - Promise.resolve({ - headers: {}, - ok: false, - redirected: false, - status: 0, - statusText: '', - type: '', - url: '', - }), -} - -export default Global diff --git a/catalog/app/utils/voila.ts b/catalog/app/utils/voila.ts new file mode 100644 index 00000000000..1834531d593 --- /dev/null +++ b/catalog/app/utils/voila.ts @@ -0,0 +1,28 @@ +import * as Config from 'utils/Config' +import * as Cache from 'utils/ResourceCache' + +const VOILA_PING_URL = (registryUrl: string) => `${registryUrl}/voila/` + +const VoilaResource = Cache.createResource({ + name: 'Voila', + persist: true, + fetch: (registryUrl: string) => + fetch(VOILA_PING_URL(registryUrl)) + .then((resp) => resp.ok) + .catch(() => false) + .then((r) => { + if (!r) { + // eslint-disable-next-line no-console + console.debug('Voila is not supported by current stack') + // TODO: add link to documentation + } + return r + }), +}) + +export function useVoila(): boolean { + const { registryUrl } = Config.use() + return Cache.useData(VoilaResource, registryUrl, { suspend: true }) +} + +export { useVoila as use } diff --git a/catalog/package-lock.json b/catalog/package-lock.json index 4e4e28f6a47..5d9c4665484 100644 --- a/catalog/package-lock.json +++ b/catalog/package-lock.json @@ -143,7 +143,6 @@ "eslint-plugin-react": "^7.24.0", "eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-redux-saga": "^1.2.1", - "fetch-mock": "^9.11.0", "file-loader": "^6.2.0", "fork-ts-checker-webpack-plugin": "^6.2.12", "html-loader": "^2.1.2", @@ -5762,17 +5761,6 @@ "node": ">=10.13.0" } }, - "node_modules/core-js": { - "version": "3.11.2", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.11.2.tgz", - "integrity": "sha512-3tfrrO1JpJSYGKnd9LKTBPqgUES/UYiCzMKeqwR1+jF16q4kD1BY2NvqkfuzXwQ6+CIWm55V9cjD7PQd+hijdw==", - "dev": true, - "hasInstallScript": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/core-js-pure": { "version": "3.11.2", "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.11.2.tgz", @@ -7908,45 +7896,6 @@ "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=", "deprecated": "core-js@<3.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Please, upgrade your dependencies to the actual version of core-js." }, - "node_modules/fetch-mock": { - "version": "9.11.0", - "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz", - "integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==", - "dev": true, - "dependencies": { - "@babel/core": "^7.0.0", - "@babel/runtime": "^7.0.0", - "core-js": "^3.0.0", - "debug": "^4.1.1", - "glob-to-regexp": "^0.4.0", - "is-subset": "^0.1.1", - "lodash.isequal": "^4.5.0", - "path-to-regexp": "^2.2.1", - "querystring": "^0.2.0", - "whatwg-url": "^6.5.0" - }, - "engines": { - "node": ">=4.0.0" - }, - "funding": { - "type": "charity", - "url": "https://www.justgiving.com/refugee-support-europe" - }, - "peerDependencies": { - "node-fetch": "*" - }, - "peerDependenciesMeta": { - "node-fetch": { - "optional": true - } - } - }, - "node_modules/fetch-mock/node_modules/path-to-regexp": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.4.0.tgz", - "integrity": "sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==", - "dev": true - }, "node_modules/fflate": { "version": "0.3.11", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz", @@ -9639,12 +9588,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-subset": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", - "integrity": "sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=", - "dev": true - }, "node_modules/is-symbol": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", @@ -12702,12 +12645,6 @@ "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=", "dev": true }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", - "dev": true - }, "node_modules/lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", @@ -16812,15 +16749,6 @@ "node": ">= 4.0.0" } }, - "node_modules/tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/traverse": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", @@ -18254,23 +18182,6 @@ "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", "dev": true }, - "node_modules/whatwg-url": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz", - "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==", - "dev": true, - "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - }, - "node_modules/whatwg-url/node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "dev": true - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -23078,12 +22989,6 @@ } } }, - "core-js": { - "version": "3.11.2", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.11.2.tgz", - "integrity": "sha512-3tfrrO1JpJSYGKnd9LKTBPqgUES/UYiCzMKeqwR1+jF16q4kD1BY2NvqkfuzXwQ6+CIWm55V9cjD7PQd+hijdw==", - "dev": true - }, "core-js-pure": { "version": "3.11.2", "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.11.2.tgz", @@ -24790,32 +24695,6 @@ "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", "dev": true }, - "fetch-mock": { - "version": "9.11.0", - "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz", - "integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==", - "dev": true, - "requires": { - "@babel/core": "^7.0.0", - "@babel/runtime": "^7.0.0", - "core-js": "^3.0.0", - "debug": "^4.1.1", - "glob-to-regexp": "^0.4.0", - "is-subset": "^0.1.1", - "lodash.isequal": "^4.5.0", - "path-to-regexp": "^2.2.1", - "querystring": "^0.2.0", - "whatwg-url": "^6.5.0" - }, - "dependencies": { - "path-to-regexp": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.4.0.tgz", - "integrity": "sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==", - "dev": true - } - } - }, "fflate": { "version": "0.3.11", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz", @@ -26076,12 +25955,6 @@ "integrity": "sha512-2gdzbKUuqtQ3lYNrUTQYoClPhm7oQu4UdpSZMp1/DGgkHBT8E2Z1l0yMdb6D4zNAxwDiMv8MdulKROJGNl0Q0w==", "dev": true }, - "is-subset": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", - "integrity": "sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=", - "dev": true - }, "is-symbol": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", @@ -28583,12 +28456,6 @@ "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=", "dev": true }, - "lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", - "dev": true - }, "lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", @@ -31744,15 +31611,6 @@ } } }, - "tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, "traverse": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", @@ -32919,25 +32777,6 @@ "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", "dev": true }, - "whatwg-url": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz", - "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==", - "dev": true, - "requires": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - }, - "dependencies": { - "webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "dev": true - } - } - }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/catalog/package.json b/catalog/package.json index f2060c5c188..44ef3584c71 100644 --- a/catalog/package.json +++ b/catalog/package.json @@ -187,7 +187,6 @@ "eslint-plugin-react": "^7.24.0", "eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-redux-saga": "^1.2.1", - "fetch-mock": "^9.11.0", "file-loader": "^6.2.0", "fork-ts-checker-webpack-plugin": "^6.2.12", "html-loader": "^2.1.2", diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3a67f2940a0..782495b0303 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -53,6 +53,7 @@ * [Fixed] Infinite spinner on logout ([#2232](https://github.com/quiltdata/quilt/pull/2232)) * [Fixed] Dismiss error page when navigating from it ([#2291](https://github.com/quiltdata/quilt/pull/2291)) * [Fixed] Avoid crash on non-existent logical keys in pkgselect detail view ([#2307](https://github.com/quiltdata/quilt/pull/2307) +* [Fixed] Error while rendering a preview inside a package ([#2328](https://github.com/quiltdata/quilt/pull/2328)) # 3.4.0 - 2021-03-15 ## Python API