diff --git a/catalog/.eslintrc.js b/catalog/.eslintrc.js index f8e68546c82..21a1a1d8d1d 100644 --- a/catalog/.eslintrc.js +++ b/catalog/.eslintrc.js @@ -51,6 +51,7 @@ module.exports = { 'max-classes-per-file': 0, 'no-console': 2, 'no-nested-ternary': 1, + 'no-restricted-globals': [2, "event", "location", "stop"], 'no-underscore-dangle': [2, { allow: ['_', '__', '__typename', '_tag'] }], 'prefer-arrow-callback': [2, { allowNamedFunctions: true }], 'prefer-template': 2, diff --git a/catalog/CHANGELOG.md b/catalog/CHANGELOG.md index dbdb686eaf1..4966d7d8362 100644 --- a/catalog/CHANGELOG.md +++ b/catalog/CHANGELOG.md @@ -17,6 +17,7 @@ where verb is one of ## Changes +- [Added] Preview Markdown while editing ([#4153](https://github.com/quiltdata/quilt/pull/4153)) - [Changed] Athena: hide data catalogs user doesn't have access to ([#4239](https://github.com/quiltdata/quilt/pull/4239)) - [Added] Enable MixPanel tracking in Embed mode ([#4237](https://github.com/quiltdata/quilt/pull/4237)) - [Fixed] Fix embed files listing ([#4236](https://github.com/quiltdata/quilt/pull/4236)) diff --git a/catalog/app/components/FileEditor/Controls.tsx b/catalog/app/components/FileEditor/Controls.tsx index 16d343afe28..be174abce4b 100644 --- a/catalog/app/components/FileEditor/Controls.tsx +++ b/catalog/app/components/FileEditor/Controls.tsx @@ -16,6 +16,26 @@ export function AddFileButton({ onClick }: AddFileButtonProps) { ) } +interface PreviewButtonProps extends EditorState { + className?: string + onPreview: NonNullable +} + +export function PreviewButton({ className, preview, onPreview }: PreviewButtonProps) { + const handleClick = React.useCallback(() => onPreview(!preview), [onPreview, preview]) + return ( + event.stopPropagation()} + className={className} + control={ + + } + label="Preview" + labelPlacement="end" + /> + ) +} + interface ControlsProps extends EditorState { className?: string } diff --git a/catalog/app/components/FileEditor/FileEditor.tsx b/catalog/app/components/FileEditor/FileEditor.tsx index 756f2990c41..e34851c84db 100644 --- a/catalog/app/components/FileEditor/FileEditor.tsx +++ b/catalog/app/components/FileEditor/FileEditor.tsx @@ -1,7 +1,10 @@ +import cx from 'classnames' import * as React from 'react' +import * as M from '@material-ui/core' -import * as PreviewUtils from 'components/Preview/loaders/utils' import PreviewDisplay from 'components/Preview/Display' +import * as PreviewUtils from 'components/Preview/loaders/utils' +import { QuickPreview } from 'components/Preview/quick' import type * as Model from 'model' import AsyncResult from 'utils/AsyncResult' @@ -95,10 +98,28 @@ function EditorSuspended({ }) } +const useStyles = M.makeStyles({ + tab: { + display: 'none', + width: '100%', + }, + active: { + display: 'block', + }, +}) + export function Editor(props: EditorProps) { + const classes = useStyles() return ( }> - +
+ +
+ {props.preview && ( +
+ +
+ )}
) } diff --git a/catalog/app/components/FileEditor/State.tsx b/catalog/app/components/FileEditor/State.tsx index 1bf86f92919..75c57b249fc 100644 --- a/catalog/app/components/FileEditor/State.tsx +++ b/catalog/app/components/FileEditor/State.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import * as RRDom from 'react-router-dom' import type * as Model from 'model' +import { isQuickPreviewAvailable } from 'components/Preview/quick' import * as AddToPackage from 'containers/AddToPackage' import * as NamedRoutes from 'utils/NamedRoutes' import parseSearch from 'utils/parseSearch' @@ -32,7 +33,9 @@ export interface EditorState { onCancel: () => void onChange: (value: string) => void onEdit: (type: EditorInputType | null) => void + onPreview: ((p: boolean) => void) | null onSave: () => Promise + preview: boolean saving: boolean types: EditorInputType[] value?: string @@ -48,6 +51,7 @@ export function useState(handle: Model.S3.S3ObjectLocation): EditorState { const [editing, setEditing] = React.useState( edit ? types[0] : null, ) + const [preview, setPreview] = React.useState(false) const [saving, setSaving] = React.useState(false) const writeFile = useWriteData(handle) const redirect = useRedirect() @@ -80,11 +84,13 @@ export function useState(handle: Model.S3.S3ObjectLocation): EditorState { onCancel, onChange: setValue, onEdit: setEditing, + onPreview: isQuickPreviewAvailable(editing) ? setPreview : null, onSave, + preview, saving, types, value, }), - [editing, error, onCancel, onSave, saving, types, value], + [editing, error, onCancel, onSave, preview, saving, types, value], ) } diff --git a/catalog/app/components/FileEditor/index.ts b/catalog/app/components/FileEditor/index.ts index 9e0b2815bfe..f3beb287509 100644 --- a/catalog/app/components/FileEditor/index.ts +++ b/catalog/app/components/FileEditor/index.ts @@ -2,3 +2,4 @@ export * from './Controls' export * from './CreateFile' export * from './FileEditor' export * from './State' +export * from './types' diff --git a/catalog/app/components/Preview/loaders/Markdown.js b/catalog/app/components/Preview/loaders/Markdown.js index 9df6261cd4b..0686a3395f8 100644 --- a/catalog/app/components/Preview/loaders/Markdown.js +++ b/catalog/app/components/Preview/loaders/Markdown.js @@ -8,7 +8,6 @@ import * as AWS from 'utils/AWS' import AsyncResult from 'utils/AsyncResult' import * as NamedRoutes from 'utils/NamedRoutes' import * as Resource from 'utils/Resource' -import pipeThru from 'utils/pipeThru' import { resolveKey } from 'utils/s3paths' import useMemoEq from 'utils/useMemoEq' @@ -66,20 +65,36 @@ function useLinkProcessor(handle) { ) } +// result: AsyncResult<{Ok: {contents: string}>, +// handle: Model.S3ObjectLocation +export function useMarkdownRenderer(contentsResult, handle) { + const processImg = useImgProcessor(handle) + const processLink = useLinkProcessor(handle) + return utils.useProcessing(contentsResult, getRenderer({ processImg, processLink }), [ + processImg, + processLink, + ]) +} + export const detect = utils.extIn(['.md', '.rmd']) function MarkdownLoader({ gated, handle, children }) { - const processImg = useImgProcessor(handle) - const processLink = useLinkProcessor(handle) const data = utils.useObjectGetter(handle, { noAutoFetch: gated }) - const processed = utils.useProcessing( - data.result, - (r) => { - const contents = r.Body.toString('utf-8') - const rendered = getRenderer({ processImg, processLink })(contents) - return PreviewData.Markdown({ rendered, modes: [FileType.Markdown, FileType.Text] }) - }, - [processImg, processLink], + const contents = React.useMemo( + () => + AsyncResult.mapCase({ + Ok: (r) => r.Body.toString('utf-8'), + })(data.result), + [data.result], + ) + const markdowned = useMarkdownRenderer(contents, handle) + const processed = React.useMemo( + () => + AsyncResult.mapCase({ + Ok: (rendered) => + PreviewData.Markdown({ rendered, modes: [FileType.Markdown, FileType.Text] }), + })(markdowned), + [markdowned], ) const handled = utils.useErrorHandling(processed, { handle, retry: data.fetch }) const result = @@ -96,10 +111,8 @@ const SIZE_THRESHOLDS = { export const Loader = function GatedMarkdownLoader({ handle, children }) { const data = useGate(handle, SIZE_THRESHOLDS) const handled = utils.useErrorHandling(data.result, { handle, retry: data.fetch }) - return pipeThru(handled)( - AsyncResult.case({ - _: children, - Ok: (gated) => , - }), - ) + return AsyncResult.case({ + _: children, + Ok: (gated) => , + })(handled) } diff --git a/catalog/app/components/Preview/quick/Markdown/Render.spec.tsx b/catalog/app/components/Preview/quick/Markdown/Render.spec.tsx new file mode 100644 index 00000000000..d6e53e440fb --- /dev/null +++ b/catalog/app/components/Preview/quick/Markdown/Render.spec.tsx @@ -0,0 +1,64 @@ +import * as React from 'react' +import renderer from 'react-test-renderer' + +import AsyncResult from 'utils/AsyncResult' + +import Render from './Render' + +jest.mock( + 'constants/config', + jest.fn(() => {}), +) + +const useMarkdownRenderer = jest.fn() +jest.mock('components/Preview/loaders/Markdown', () => ({ + ...jest.requireActual('components/Preview/loaders/Markdown'), + useMarkdownRenderer: jest.fn(() => useMarkdownRenderer()), +})) + +jest.mock( + 'components/Preview/renderers/Markdown', + () => + ({ rendered }: { rendered: string }) => ( + // eslint-disable-next-line react/no-danger + Markdown + ), +) + +jest.mock( + '@material-ui/lab', + jest.fn(() => ({ + Alert: ({ children }: { children: string }) =>
Error: {children}
, + })), +) + +const handle = { + bucket: 'foo', + key: 'bar', +} + +describe('app/components/Preview/quick/Render.spec.tsx', () => { + it('it shows the error for Init state, because it is intended to run with already resolved value', () => { + useMarkdownRenderer.mockReturnValue(AsyncResult.Init()) + const tree = renderer.create().toJSON() + expect(tree).toMatchSnapshot() + }) + + it('it shows the error for Pending state, because it is intended to run with already resolved value', () => { + useMarkdownRenderer.mockReturnValue(AsyncResult.Pending()) + const tree = renderer.create().toJSON() + expect(tree).toMatchSnapshot() + }) + + it('returns error on Err', () => { + useMarkdownRenderer.mockReturnValue(AsyncResult.Err(new Error('some error'))) + const tree = renderer.create().toJSON() + expect(tree).toMatchSnapshot() + }) + + it('returns markdown on data', () => { + useMarkdownRenderer.mockReturnValue(AsyncResult.Ok('

It works

')) + const tree = renderer.create().toJSON() + expect(tree).toMatchSnapshot() + }) +}) diff --git a/catalog/app/components/Preview/quick/Markdown/Render.tsx b/catalog/app/components/Preview/quick/Markdown/Render.tsx new file mode 100644 index 00000000000..fecc993e33d --- /dev/null +++ b/catalog/app/components/Preview/quick/Markdown/Render.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' +import * as Lab from '@material-ui/lab' + +import { useMarkdownRenderer } from 'components/Preview/loaders/Markdown' +import Markdown from 'components/Preview/renderers/Markdown' +import type * as Model from 'model' +import AsyncResult from 'utils/AsyncResult' + +import Skeleton from './Skeleton' + +interface RenderProps { + value: string + handle: Model.S3.S3ObjectLocation +} + +export default function Render({ value, handle }: RenderProps) { + const result = useMarkdownRenderer(AsyncResult.Ok(value), handle) + // `result` is never `Pending` or `Init`, because we pass the `AsyncResult.Ok` value + // but it can be `Err` if some post-processing fails + return AsyncResult.case({ + _: (x: { value?: Error }) => ( + {x.value?.message || 'Unexpected state'} + ), + Ok: (rendered: string) => ( + }> + + + ), + })(result) +} diff --git a/catalog/app/components/Preview/quick/Markdown/Skeleton.tsx b/catalog/app/components/Preview/quick/Markdown/Skeleton.tsx new file mode 100644 index 00000000000..1217307a1cb --- /dev/null +++ b/catalog/app/components/Preview/quick/Markdown/Skeleton.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' +import * as M from '@material-ui/core' + +import Skel from 'components/Skeleton' + +const useSkeletonStyles = M.makeStyles((t) => ({ + line: { + height: t.spacing(3), + marginBottom: t.spacing(1), + }, +})) + +const LINES = [80, 50, 100, 60, 30, 80, 50, 100, 60, 30, 20, 70] + +export default function Skeleton() { + const classes = useSkeletonStyles() + return ( +
+ {LINES.map((width, index) => ( + + ))} +
+ ) +} diff --git a/catalog/app/components/Preview/quick/Markdown/__snapshots__/Render.spec.tsx.snap b/catalog/app/components/Preview/quick/Markdown/__snapshots__/Render.spec.tsx.snap new file mode 100644 index 00000000000..677c32dc08e --- /dev/null +++ b/catalog/app/components/Preview/quick/Markdown/__snapshots__/Render.spec.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`app/components/Preview/quick/Render.spec.tsx it shows the error for Init state, because it is intended to run with already resolved value 1`] = ` +
+ Error: + Unexpected state +
+`; + +exports[`app/components/Preview/quick/Render.spec.tsx it shows the error for Pending state, because it is intended to run with already resolved value 1`] = ` +
+ Error: + Unexpected state +
+`; + +exports[`app/components/Preview/quick/Render.spec.tsx returns error on Err 1`] = ` +
+ Error: + some error +
+`; + +exports[`app/components/Preview/quick/Render.spec.tsx returns markdown on data 1`] = ` +It works", + } + } +> + Markdown + +`; diff --git a/catalog/app/components/Preview/quick/Markdown/index.tsx b/catalog/app/components/Preview/quick/Markdown/index.tsx new file mode 100644 index 00000000000..31ab4311b97 --- /dev/null +++ b/catalog/app/components/Preview/quick/Markdown/index.tsx @@ -0,0 +1,15 @@ +import * as React from 'react' + +import Skeleton from './Skeleton' + +const RenderLazy = React.lazy(() => import('./Render')) + +export { default as Skeleton } from './Skeleton' + +export function Render(props: Parameters[0]) { + return ( + }> + + + ) +} diff --git a/catalog/app/components/Preview/quick/__snapshots__/index.spec.tsx.snap b/catalog/app/components/Preview/quick/__snapshots__/index.spec.tsx.snap new file mode 100644 index 00000000000..790abb3419a --- /dev/null +++ b/catalog/app/components/Preview/quick/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`app/components/Preview/quick/index.spec.tsx QuickPreview renders Markdown 1`] = ` +

+ This is Markdown quick preview +

+`; + +exports[`app/components/Preview/quick/index.spec.tsx QuickPreview renders no preview if unsupported file type 1`] = ` +

+ There is no content for quick preview +

+`; + +exports[`app/components/Preview/quick/index.spec.tsx QuickPreview renders no value 1`] = ` +

+ There is no content for quick preview +

+`; diff --git a/catalog/app/components/Preview/quick/index.spec.tsx b/catalog/app/components/Preview/quick/index.spec.tsx new file mode 100644 index 00000000000..c5f9d939fd3 --- /dev/null +++ b/catalog/app/components/Preview/quick/index.spec.tsx @@ -0,0 +1,62 @@ +import * as React from 'react' +import renderer from 'react-test-renderer' + +import type * as FileEditor from 'components/FileEditor' + +import { QuickPreview, isQuickPreviewAvailable } from './index' + +jest.mock('./Markdown', () => ({ + ...jest.requireActual('./Markdown'), + Render: () =>

This is Markdown quick preview

, +})) + +describe('app/components/Preview/quick/index.spec.tsx', () => { + describe('isQuickPreviewAvailable', () => { + it('should say if quick preview is available', () => { + const types: FileEditor.EditorInputType[] = [ + 'markdown' as const, + 'json' as const, + null, + ].map((brace) => ({ brace })) + expect(types.map(isQuickPreviewAvailable)).toEqual([true, false, false]) + }) + }) + + describe('QuickPreview', () => { + const handle = { + bucket: 'foo', + key: 'bar', + } + + it('renders no value', () => { + const tree = renderer + .create( + , + ) + .toJSON() + expect(tree).toMatchSnapshot() + }) + + it('renders no preview if unsupported file type', () => { + const tree = renderer + .create( + , + ) + .toJSON() + expect(tree).toMatchSnapshot() + }) + + it('renders Markdown', () => { + const tree = renderer + .create( + , + ) + .toJSON() + expect(tree).toMatchSnapshot() + }) + }) +}) diff --git a/catalog/app/components/Preview/quick/index.tsx b/catalog/app/components/Preview/quick/index.tsx new file mode 100644 index 00000000000..0f5d8bf2b6d --- /dev/null +++ b/catalog/app/components/Preview/quick/index.tsx @@ -0,0 +1,61 @@ +import * as React from 'react' +import * as M from '@material-ui/core' + +import * as FileEditor from 'components/FileEditor' +import type * as Model from 'model' +import assertNever from 'utils/assertNever' + +import * as Markdown from './Markdown' + +function NoValue() { + return There is no content for quick preview +} + +function NoPreview() { + return Quick preview is not available for this type +} + +interface HandledMarkdown { + tag: 'markdown' +} + +interface HandledNone { + tag: 'none' +} + +type HandledType = HandledNone | HandledMarkdown // Json | Yaml + +function convertToTypeUnion(type: FileEditor.EditorInputType | null): HandledType { + if (!type) return { tag: 'none' } + switch (type.brace) { + case 'markdown': + return { tag: 'markdown' } + default: + return { tag: 'none' } + } +} + +export function isQuickPreviewAvailable(type: FileEditor.EditorInputType | null) { + return convertToTypeUnion(type).tag !== 'none' +} + +interface TextPreviewProps { + handle: Model.S3.S3ObjectLocation + type: FileEditor.EditorInputType + value?: string +} + +export function QuickPreview({ handle, type, value }: TextPreviewProps) { + if (!value) return + + const previewType = convertToTypeUnion(type) + + switch (previewType.tag) { + case 'markdown': + return + case 'none': + return + default: + assertNever(previewType) + } +} diff --git a/catalog/app/containers/Bucket/File/File.js b/catalog/app/containers/Bucket/File/File.js index fda4ceb1d40..ed484e40d7c 100644 --- a/catalog/app/containers/Bucket/File/File.js +++ b/catalog/app/containers/Bucket/File/File.js @@ -251,6 +251,15 @@ const useStyles = M.makeStyles((t) => ({ marginBottom: t.spacing(2), flexWrap: 'wrap', }, + editTitle: { + alignItems: 'center', + display: 'flex', + flexGrow: 1, + }, + editButton: { + margin: '0 0 0 auto', + textTransform: 'none', + }, preview: { width: '100%', }, @@ -481,7 +490,21 @@ export default function File() { prefs, )} {editorState.editing ? ( -
+
+ Edit content + {editorState.onPreview && ( + + )} + + } + expandable={false} + > ({ }, heading: { display: 'flex', + flexGrow: 1, }, gutterBottom: { marginBottom: t.spacing(2),