Skip to content

Commit

Permalink
Preview markdown files (#4153)
Browse files Browse the repository at this point in the history
Co-authored-by: Alexei Mochalov <[email protected]>
  • Loading branch information
fiskus and nl0 authored Nov 25, 2024
1 parent d8e208c commit ee00195
Show file tree
Hide file tree
Showing 17 changed files with 421 additions and 21 deletions.
1 change: 1 addition & 0 deletions catalog/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions catalog/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
20 changes: 20 additions & 0 deletions catalog/app/components/FileEditor/Controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,26 @@ export function AddFileButton({ onClick }: AddFileButtonProps) {
)
}

interface PreviewButtonProps extends EditorState {
className?: string
onPreview: NonNullable<EditorState['onPreview']>
}

export function PreviewButton({ className, preview, onPreview }: PreviewButtonProps) {
const handleClick = React.useCallback(() => onPreview(!preview), [onPreview, preview])
return (
<M.FormControlLabel
onClick={(event) => event.stopPropagation()}
className={className}
control={
<M.Switch checked={preview} onChange={handleClick} size="small" color="primary" />
}
label="Preview"
labelPlacement="end"
/>
)
}

interface ControlsProps extends EditorState {
className?: string
}
Expand Down
25 changes: 23 additions & 2 deletions catalog/app/components/FileEditor/FileEditor.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 (
<React.Suspense fallback={<Skeleton />}>
<EditorSuspended {...props} />
<div className={cx(classes.tab, { [classes.active]: !props.preview })}>
<EditorSuspended {...props} />
</div>
{props.preview && (
<div className={cx(classes.tab, classes.active)}>
<QuickPreview handle={props.handle} type={props.editing} value={props.value} />
</div>
)}
</React.Suspense>
)
}
8 changes: 7 additions & 1 deletion catalog/app/components/FileEditor/State.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<Model.S3File | void>
preview: boolean
saving: boolean
types: EditorInputType[]
value?: string
Expand All @@ -48,6 +51,7 @@ export function useState(handle: Model.S3.S3ObjectLocation): EditorState {
const [editing, setEditing] = React.useState<EditorInputType | null>(
edit ? types[0] : null,
)
const [preview, setPreview] = React.useState<boolean>(false)
const [saving, setSaving] = React.useState<boolean>(false)
const writeFile = useWriteData(handle)
const redirect = useRedirect()
Expand Down Expand Up @@ -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],
)
}
1 change: 1 addition & 0 deletions catalog/app/components/FileEditor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './Controls'
export * from './CreateFile'
export * from './FileEditor'
export * from './State'
export * from './types'
47 changes: 30 additions & 17 deletions catalog/app/components/Preview/loaders/Markdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 =
Expand All @@ -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) => <MarkdownLoader {...{ gated, handle, children }} />,
}),
)
return AsyncResult.case({
_: children,
Ok: (gated) => <MarkdownLoader {...{ gated, handle, children }} />,
})(handled)
}
64 changes: 64 additions & 0 deletions catalog/app/components/Preview/quick/Markdown/Render.spec.tsx
Original file line number Diff line number Diff line change
@@ -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
<b dangerouslySetInnerHTML={{ __html: rendered }}>Markdown</b>
),
)

jest.mock(
'@material-ui/lab',
jest.fn(() => ({
Alert: ({ children }: { children: string }) => <div>Error: {children}</div>,
})),
)

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(<Render {...{ handle, value: 'any' }} />).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(<Render {...{ handle, value: 'any' }} />).toJSON()
expect(tree).toMatchSnapshot()
})

it('returns error on Err', () => {
useMarkdownRenderer.mockReturnValue(AsyncResult.Err(new Error('some error')))
const tree = renderer.create(<Render {...{ handle, value: 'any' }} />).toJSON()
expect(tree).toMatchSnapshot()
})

it('returns markdown on data', () => {
useMarkdownRenderer.mockReturnValue(AsyncResult.Ok('<h1>It works</h1>'))
const tree = renderer.create(<Render {...{ handle, value: 'any' }} />).toJSON()
expect(tree).toMatchSnapshot()
})
})
30 changes: 30 additions & 0 deletions catalog/app/components/Preview/quick/Markdown/Render.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<Lab.Alert severity="error">{x.value?.message || 'Unexpected state'}</Lab.Alert>
),
Ok: (rendered: string) => (
<React.Suspense fallback={<Skeleton />}>
<Markdown rendered={rendered} />
</React.Suspense>
),
})(result)
}
24 changes: 24 additions & 0 deletions catalog/app/components/Preview/quick/Markdown/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
{LINES.map((width, index) => (
<Skel className={classes.line} width={`${width}%`} key={width + index} />
))}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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`] = `
<div>
Error:
Unexpected state
</div>
`;

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`] = `
<div>
Error:
Unexpected state
</div>
`;

exports[`app/components/Preview/quick/Render.spec.tsx returns error on Err 1`] = `
<div>
Error:
some error
</div>
`;

exports[`app/components/Preview/quick/Render.spec.tsx returns markdown on data 1`] = `
<b
dangerouslySetInnerHTML={
{
"__html": "<h1>It works</h1>",
}
}
>
Markdown
</b>
`;
15 changes: 15 additions & 0 deletions catalog/app/components/Preview/quick/Markdown/index.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof RenderLazy>[0]) {
return (
<React.Suspense fallback={<Skeleton />}>
<RenderLazy {...props} />
</React.Suspense>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`app/components/Preview/quick/index.spec.tsx QuickPreview renders Markdown 1`] = `
<h1>
This is Markdown quick preview
</h1>
`;

exports[`app/components/Preview/quick/index.spec.tsx QuickPreview renders no preview if unsupported file type 1`] = `
<p
className="MuiTypography-root MuiTypography-body1"
>
There is no content for quick preview
</p>
`;

exports[`app/components/Preview/quick/index.spec.tsx QuickPreview renders no value 1`] = `
<p
className="MuiTypography-root MuiTypography-body1"
>
There is no content for quick preview
</p>
`;
Loading

0 comments on commit ee00195

Please sign in to comment.