onCell(item)}
+ {...props}
+ />
+ ),
+ [onCell],
+ )
+ return (
+
+ )
+}
+
+const useFilePickerSkeletonStyles = M.makeStyles((t) => ({
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ toolbar: {
+ display: 'flex',
+ },
+ toolbarSkeleton: {
+ width: t.spacing(20),
+ height: t.spacing(4.5) - 20 /*margin*/,
+ margin: '10px 0 10px auto',
+ },
+ divided: {
+ height: t.spacing(4.5) - 2 /*border*/ - 20 /*margin*/,
+ margin: '10px 0',
+ },
+ item: {
+ height: t.spacing(4.5) - 20 /*margin*/,
+ margin: '10px 0',
+ },
+}))
+
+function FilePickerSkeleton() {
+ const classes = useFilePickerSkeletonStyles()
+ const widths = React.useMemo(
+ () =>
+ Array.from({ length: 25 }).map(
+ () => `${Math.min(75, Math.max(25, Math.ceil(Math.random() * 100)))}%`,
+ ),
+ [],
+ )
+ return (
+
+
+
+
+
+
+
+ {widths.map((width, i) => (
+
+ ))}
+
+
+
+
+
+
+
+ )
+}
+
+const useFilePickerDialogStyles = M.makeStyles({
+ dialog: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '80vh',
+ },
+})
+
+interface FilePickerDialogProps {
+ bucket: string
+ initialPath: string
+ onClose: () => void
+ submit: (path: string) => void
+}
+
+function FilePickerDialog({
+ bucket,
+ initialPath,
+ onClose,
+ submit,
+}: FilePickerDialogProps) {
+ const classes = useFilePickerDialogStyles()
+ const [path, setPath] = React.useState(initialPath)
+ const bucketListing = requests.useBucketListing()
+ const data = useData(bucketListing, {
+ bucket,
+ path,
+ prefix: '',
+ prev: null,
+ drain: true,
+ })
+ const handleCellClick = React.useCallback(
+ (item: Listing.Item) => {
+ if (item.type === 'dir') {
+ setPath(item.to)
+ } else {
+ submit(item.to)
+ }
+ },
+ [submit],
+ )
+ return (
+ <>
+
+
+ {data.case({
+ _: () => ,
+ Ok: (res: requests.BucketListingResult) => (
+
+ ),
+ })}
+
+
+
+ Cancel
+
+ >
+ )
+}
+
+const useAddColumnStyles = M.makeStyles((t) => ({
+ root: {
+ animation: '$show 0.15s ease-out',
+ display: 'flex',
+ '&:last-child $divider': {
+ marginLeft: t.spacing(4),
+ },
+ },
+ inner: {
+ flexGrow: 1,
+ display: 'flex',
+ flexDirection: 'column',
+ position: 'relative',
+ '&:has($close:hover) $path': {
+ opacity: 0.3,
+ },
+ '&:has($close:hover) $extended': {
+ opacity: 0.3,
+ },
+ },
+ settings: {
+ margin: t.spacing(0, 1, -1, -0.5),
+ transition: 'transform 0.15s ease-out',
+ '&:hover': {
+ transform: 'rotate(180deg)',
+ },
+ },
+ extended: {
+ animation: '$slide 0.15s ease-out',
+ transition: 'opacity 0.3s ease-out',
+ paddingLeft: t.spacing(7),
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ expanded: {
+ background: t.palette.background.paper,
+ position: 'absolute',
+ right: '16px',
+ top: '4px',
+ },
+ path: {
+ transition: 'opacity 0.3s ease-out',
+ display: 'flex',
+ alignItems: 'flex-end',
+ },
+ field: {
+ marginTop: t.spacing(1),
+ minWidth: t.spacing(10),
+ },
+ divider: {
+ marginLeft: t.spacing(2),
+ },
+ render: {
+ border: `1px solid ${t.palette.divider}`,
+ borderRadius: t.shape.borderRadius,
+ marginTop: t.spacing(3),
+ padding: t.spacing(2),
+ },
+ select: {
+ marginTop: t.spacing(2),
+ },
+ toggle: {
+ padding: t.spacing(1, 0, 0),
+ },
+ close: {
+ position: 'absolute',
+ right: 0,
+ top: 0,
+ },
+ '@keyframes slide': {
+ from: {
+ opacity: 0,
+ transform: 'translateY(-8px)',
+ },
+ to: {
+ opacity: 1,
+ transform: 'translateY(0)',
+ },
+ },
+ '@keyframes show': {
+ from: {
+ opacity: 0,
+ transform: 'scale(0.9)',
+ },
+ to: {
+ opacity: 1,
+ transform: 'scale(1)',
+ },
+ },
+}))
+
+interface AddColumnProps {
+ className: string
+ column: Column
+ disabled?: boolean
+ onChange: React.Dispatch
>
+ row: Row
+ last: boolean
+}
+
+function AddColumn({ className, column, disabled, last, onChange, row }: AddColumnProps) {
+ const { bucket, initialPath } = useParams()
+
+ const classes = useAddColumnStyles()
+ const { file } = column
+ const [advanced, setAdvanced] = React.useState(file.isExtended)
+
+ const onChangeValue = React.useCallback(
+ (key: keyof FileExtended, value: FileExtended[keyof FileExtended]) => {
+ const dispatch = State.changeValue(row.id, column.id)
+ onChange(dispatch({ [key]: value }))
+ },
+ [onChange, row.id, column.id],
+ )
+
+ const onChangeType = React.useCallback(
+ (
+ key: keyof Summarize.TypeExtended,
+ value: Summarize.TypeExtended[keyof Summarize.TypeExtended],
+ ) =>
+ onChangeValue('type', {
+ ...((file.type || {}) as Summarize.TypeExtended),
+ [key]: value,
+ }),
+ [onChangeValue, file.type],
+ )
+
+ const onRemove = React.useCallback(
+ () => onChange(State.removeColumn(row.id, column.id)),
+ [onChange, row.id, column.id],
+ )
+
+ const pickPath = React.useCallback(
+ (path: string, close: () => void) => {
+ onChangeValue('path', relative(initialPath, path))
+ close()
+ },
+ [initialPath, onChangeValue],
+ )
+
+ const openDialog = Dialogs.use()
+ const handlePicker = React.useCallback(() => {
+ openDialog(
+ ({ close }) => (
+ pickPath(path, close)}
+ />
+ ),
+ { maxWidth: 'xl' as const, fullWidth: true },
+ )
+ }, [bucket, initialPath, openDialog, pickPath])
+
+ return (
+
+
+
+ setAdvanced((a) => !a)}
+ color={advanced ? 'primary' : 'default'}
+ >
+ settings
+
+ onChangeValue('path', event.currentTarget.value)}
+ value={file.path || ''}
+ fullWidth
+ InputProps={{
+ startAdornment: (
+
+
+ attach_file
+
+
+ ),
+ }}
+ />
+
+ {advanced && (
+
+ onChangeValue('title', event.currentTarget.value)}
+ value={file.title || ''}
+ fullWidth
+ className={classes.field}
+ size="small"
+ />
+
+ onChangeValue('description', event.currentTarget.value)
+ }
+ value={file.description || ''}
+ fullWidth
+ className={classes.field}
+ size="small"
+ />
+
+
+ Preview
+ onChangeValue('expand', expand)}
+ size="small"
+ />
+ }
+ labelPlacement="start"
+ label="Expand"
+ title="Whether preview is expanded by default or not"
+ />
+
+ {row.columns.length > 1 && (
+
+ onChangeValue('width', event.currentTarget.value)
+ }
+ value={file.width || ''}
+ fullWidth
+ className={classes.field}
+ size="small"
+ helperText="Width in pixels or percent"
+ />
+ )}
+
+
+ Renderer
+
+ onChangeType('name', event.target.value as Summarize.TypeShorthand)
+ }
+ >
+
+ Default
+
+ {State.schema.definitions.typeShorthand.enum.map((type) => (
+
+ {type}
+
+ ))}
+
+
+
+ {file.type && (
+
+ onChangeType('style', { height: event.currentTarget.value })
+ }
+ value={file.type.style?.height || ''}
+ fullWidth
+ className={classes.field}
+ size="small"
+ placeholder="Ex., 1000px"
+ helperText="Height as an absolute value (in `px`, `vh`, `em` etc.)"
+ />
+ )}
+
+ {file.type?.name === 'perspective' && (
+ onChangeType('config', c)}
+ helperText="Restores renderer state using a previously saved configuration. Configuration must be a valid JSON object."
+ value={file.type.config as JsonRecord}
+ fullWidth
+ className={classes.field}
+ size="small"
+ />
+ )}
+
+ {file.type?.name === 'perspective' && (
+ onChangeType('settings', checked)}
+ checked={file.type.settings || false}
+ size="small"
+ />
+ }
+ label="Show perspective toolbar"
+ className={cx(classes.field, classes.toggle)}
+ />
+ )}
+
+
+
+ )}
+
+ close
+
+
+
onChange(State.addColumnAfter(row.id, column.id)(State.emptyFile))}
+ variant="vertical"
+ />
+
+ )
+}
+
+const usePlaceholderStyles = M.makeStyles((t) => ({
+ disabled: {},
+ expanded: {},
+ horizontal: {},
+ vertical: {},
+ root: {
+ padding: t.spacing(2),
+ position: 'relative',
+ '&:hover:not($expanded):not($disabled) $icon': {
+ display: 'block',
+ },
+ '&:hover:not($expanded):not($disabled) $inner': {
+ outlineOffset: '-4px',
+ },
+ },
+ icon: {
+ display: 'none',
+ transition: 'transform 0.15s ease-out',
+ '$expanded &': {
+ display: 'block',
+ },
+ '$root:hover &': {
+ transform: 'rotate(90deg)',
+ },
+ },
+ inner: {
+ alignItems: 'center',
+ background: t.palette.divider,
+ borderRadius: t.shape.borderRadius,
+ bottom: 0,
+ color: t.palette.background.paper,
+ cursor: 'pointer',
+ display: 'flex',
+ justifyContent: 'center',
+ left: 0,
+ outline: `2px dashed ${t.palette.background.paper}`,
+ outlineOffset: '-2px',
+ overflow: 'hidden',
+ position: 'absolute',
+ right: 0,
+ top: 0,
+ transition:
+ 'top 0.15s ease-out, bottom 0.15s ease-out,left 0.15s ease-out, right 0.15s ease-out',
+ '$expanded &:hover': {
+ opacity: 1,
+ },
+ '$horizontal:not($expanded):not(:hover) &': {
+ bottom: `calc(${t.spacing(2)}px - 1px)`,
+ top: `calc(${t.spacing(2)}px - 1px)`,
+ },
+ '$vertical:not($expanded):not(:hover) &': {
+ left: `calc(${t.spacing(2)}px - 1px)`,
+ right: `calc(${t.spacing(2)}px - 1px)`,
+ },
+ '$expanded &': {
+ opacity: 0.7,
+ outlineOffset: '-4px',
+ },
+ },
+}))
+
+interface PlaceholderProps {
+ className?: string
+ onClick: () => void
+ disabled?: boolean
+ expanded: boolean
+ variant: 'horizontal' | 'vertical'
+}
+
+function Placeholder({
+ className,
+ expanded,
+ disabled,
+ onClick,
+ variant,
+}: PlaceholderProps) {
+ const classes = usePlaceholderStyles()
+ return (
+
+ )
+}
+
+const useAddRowStyles = M.makeStyles((t) => ({
+ root: {
+ '&:last-child $divider': {
+ marginTop: t.spacing(4),
+ },
+ },
+ inner: {
+ display: 'flex',
+ },
+ add: {
+ marginLeft: t.spacing(4),
+ width: t.spacing(10),
+ },
+ column: {
+ flexGrow: 1,
+ },
+ divider: {
+ marginTop: t.spacing(2),
+ },
+}))
+
+interface AddRowProps {
+ className: string
+ disabled?: boolean
+ row: Row
+ onChange: React.Dispatch>
+ last: boolean
+}
+
+function AddRow({ className, onChange, disabled, row, last }: AddRowProps) {
+ const classes = useAddRowStyles()
+
+ const onAdd = React.useCallback(
+ () => onChange(State.addRowAfter(row.id)),
+ [onChange, row.id],
+ )
+
+ return (
+
+
+ {row.columns.map((column, index) => (
+
+ ))}
+
+
+
+ )
+}
+
+const useStyles = M.makeStyles((t) => ({
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ error: {
+ marginBottom: t.spacing(2),
+ },
+ caption: {
+ ...t.typography.body2,
+ marginTop: t.spacing(2),
+ textAlign: 'center',
+ },
+ row: {
+ marginTop: t.spacing(2),
+ },
+}))
+
+export default function QuiltSummarize({
+ className,
+ disabled,
+ error,
+ initialValue,
+ onChange,
+}: QuiltConfigEditorProps) {
+ const classes = useStyles()
+ const { layout, setLayout } = State.use()
+ const [errors, setErrors] = React.useState<[Error] | ErrorObject[]>(
+ error ? [error] : [],
+ )
+
+ React.useEffect(() => {
+ if (!initialValue) return
+ try {
+ setLayout(State.init(State.parse(initialValue)))
+ } catch (e) {
+ if (Array.isArray(e)) {
+ setErrors(e)
+ } else {
+ setErrors([e instanceof Error ? e : new Error(`${e}`)])
+ }
+ }
+ }, [initialValue, setLayout])
+
+ const [value] = useDebounce(layout, 300)
+ React.useEffect(() => {
+ try {
+ onChange(State.stringify(value))
+ } catch (e) {
+ if (Array.isArray(e)) {
+ setErrors(e)
+ } else {
+ setErrors([e instanceof Error ? e : new Error(`${e}`)])
+ }
+ }
+ }, [onChange, value])
+
+ return (
+
+ {!!errors.length && (
+
+ )}
+
+
+ {layout.rows.map((row, index) => (
+
+ ))}
+ {!layout.rows.length && (
+
setLayout(State.init())}
+ disabled={disabled}
+ />
+ )}
+
+
+
+ Configuration for quilt_summarize.json. See{' '}
+
+ the docs
+
+
+
+ )
+}
diff --git a/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/State.spec.tsx b/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/State.spec.tsx
new file mode 100644
index 00000000000..4350fc622bc
--- /dev/null
+++ b/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/State.spec.tsx
@@ -0,0 +1,254 @@
+import {
+ addColumnAfter,
+ addRowAfter,
+ changeValue,
+ emptyFile,
+ init,
+ parse,
+ removeColumn,
+ stringify,
+} from './State'
+import type { Layout } from './State'
+
+const ID = expect.any(String)
+
+describe('components/FileEditor/QuiltConfigEditor/QuiltSummarize/State', () => {
+ describe('emptyFile', () => {
+ it('should return an empty file', () => {
+ expect(emptyFile).toEqual({ path: '', isExtended: false })
+ })
+ })
+ describe('init', () => {
+ it('should return an initial layout', () => {
+ expect(init()()).toEqual({
+ rows: [
+ {
+ id: ID,
+ columns: [{ id: ID, file: { path: '', isExtended: false } }],
+ },
+ ],
+ })
+ })
+ it('should return an parsed layout', () => {
+ const parsed = { rows: [] }
+ expect(init(parsed)()).toBe(parsed)
+ })
+ })
+
+ describe('addRowAfter', () => {
+ it('adds row', () => {
+ const layout = {
+ rows: [
+ { id: '1', columns: [] },
+ { id: '2', columns: [] },
+ ],
+ }
+ expect(addRowAfter('1')(layout)).toEqual({
+ rows: [
+ { id: '1', columns: [] },
+ { id: ID, columns: [{ id: ID, file: emptyFile }] },
+ { id: '2', columns: [] },
+ ],
+ })
+ })
+ })
+
+ describe('addColumn', () => {
+ it('adds column', () => {
+ const layout = {
+ rows: [
+ { id: '1', columns: [] },
+ {
+ id: '2',
+ columns: [
+ { id: '21', file: emptyFile },
+ { id: '22', file: emptyFile },
+ ],
+ },
+ { id: '3', columns: [] },
+ ],
+ }
+ const file = { path: 'foo', isExtended: false }
+ expect(addColumnAfter('2', '21')(file)(layout)).toEqual({
+ rows: [
+ { id: '1', columns: [] },
+ {
+ id: '2',
+ columns: [
+ { id: '21', file: emptyFile },
+ { id: ID, file },
+ { id: '22', file: emptyFile },
+ ],
+ },
+ { id: '3', columns: [] },
+ ],
+ })
+ })
+ })
+
+ describe('changeValue', () => {
+ it('changes value', () => {
+ const file = { path: 'foo', title: 'bar', description: 'baz', isExtended: true }
+ const layout = {
+ rows: [
+ { id: '1', columns: [] },
+ {
+ id: '2',
+ columns: [
+ { id: '21', file: emptyFile },
+ { id: '22', file },
+ { id: '23', file: emptyFile },
+ ],
+ },
+ { id: '3', columns: [] },
+ ],
+ }
+ expect(changeValue('2', '22')({ title: 'oof', path: 'rab' })(layout)).toEqual({
+ rows: [
+ { id: '1', columns: [] },
+ {
+ id: '2',
+ columns: [
+ { id: '21', file: emptyFile },
+ {
+ id: '22',
+ file: { path: 'rab', title: 'oof', description: 'baz', isExtended: true },
+ },
+ { id: '23', file: emptyFile },
+ ],
+ },
+ { id: '3', columns: [] },
+ ],
+ })
+ })
+ })
+
+ describe('removeColumn', () => {
+ it('removes column', () => {
+ const layout = {
+ rows: [
+ { id: '1', columns: [] },
+ {
+ id: '2',
+ columns: [
+ { id: '21', file: emptyFile },
+ { id: '22', file: emptyFile },
+ { id: '23', file: emptyFile },
+ ],
+ },
+ { id: '3', columns: [] },
+ ],
+ }
+ expect(removeColumn('2', '22')(layout)).toEqual({
+ rows: [
+ { id: '1', columns: [] },
+ {
+ id: '2',
+ columns: [
+ { id: '21', file: emptyFile },
+ { id: '23', file: emptyFile },
+ ],
+ },
+ { id: '3', columns: [] },
+ ],
+ })
+ })
+ it('removes row', () => {
+ const layout = {
+ rows: [
+ { id: '1', columns: [] },
+ {
+ id: '2',
+ columns: [{ id: '21', file: emptyFile }],
+ },
+ ],
+ }
+ expect(removeColumn('2', '21')(layout)).toEqual({
+ rows: [{ id: '1', columns: [] }],
+ })
+ })
+ })
+
+ describe('parse and stringify', () => {
+ const quiltSummarize = `[
+ "foo",
+ [
+ "left",
+ "right"
+ ],
+ {
+ "types": [
+ "json"
+ ],
+ "path": "baz",
+ "description": "Desc",
+ "title": "Title",
+ "expand": true,
+ "width": "1px"
+ },
+ {
+ "types": [
+ {
+ "name": "perspective",
+ "style": {
+ "height": "2px"
+ },
+ "config": {
+ "columns": [
+ "a",
+ "b"
+ ]
+ },
+ "settings": true
+ }
+ ],
+ "path": "any"
+ }
+]`
+ const layout = {
+ rows: [
+ { columns: [{ file: { path: 'foo', isExtended: false } }] },
+ { columns: [{ file: { path: 'left' } }, { file: { path: 'right' } }] },
+ {
+ columns: [
+ {
+ file: {
+ path: 'baz',
+ description: 'Desc',
+ title: 'Title',
+ expand: true,
+ width: '1px',
+ type: { name: 'json' },
+ },
+ },
+ ],
+ },
+ {
+ columns: [
+ {
+ file: {
+ path: 'any',
+ type: {
+ name: 'perspective',
+ style: {
+ height: '2px',
+ },
+ config: { columns: ['a', 'b'] },
+ settings: true,
+ },
+ },
+ },
+ ],
+ },
+ ],
+ }
+
+ it('parse config and make all shortcuts objects', () => {
+ expect(parse(quiltSummarize)).toMatchObject(layout)
+ })
+
+ it('convert layout state back to config', () => {
+ expect(stringify(layout as Layout)).toBe(quiltSummarize)
+ })
+ })
+})
diff --git a/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/State.tsx b/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/State.tsx
new file mode 100644
index 00000000000..e8cd9270826
--- /dev/null
+++ b/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/State.tsx
@@ -0,0 +1,198 @@
+import * as React from 'react'
+
+import quiltSummarizeSchema from 'schemas/quilt_summarize.json'
+
+import type * as Summarize from 'components/Preview/loaders/summarize'
+import { makeSchemaValidator } from 'utils/JSONSchema'
+
+export { default as schema } from 'schemas/quilt_summarize.json'
+
+export interface FileExtended extends Omit {
+ isExtended: boolean
+ type?: Summarize.TypeExtended
+}
+
+export interface Column {
+ id: string
+ file: FileExtended
+}
+
+export interface Row {
+ id: string
+ columns: Column[]
+}
+
+export interface Layout {
+ rows: Row[]
+}
+
+const pathToFile = (path: string): FileExtended => ({ path, isExtended: false })
+
+export const emptyFile: FileExtended = pathToFile('')
+
+const createColumn = (file: FileExtended): Column => ({
+ id: crypto.randomUUID(),
+ file,
+})
+
+const createRow = (file: FileExtended): Row => ({
+ id: crypto.randomUUID(),
+ columns: [createColumn(file)],
+})
+
+export const init = (payload?: Layout) => (): Layout =>
+ payload || {
+ rows: [createRow(emptyFile)],
+ }
+
+function insert(array: T[], index: number, item: T): T[] {
+ return array.toSpliced(index, 0, item)
+}
+
+function insertAfter(array: T[], id: string, item: T): T[] {
+ const index = array.findIndex((r) => r.id === id)
+ return insert(array, index + 1, item)
+}
+
+type Callback = (item: T) => T
+function replace(array: T[], id: string, cb: Callback): T[] {
+ const index = array.findIndex((r) => r.id === id)
+ return array.toSpliced(index, 1, cb(array[index]))
+}
+
+export const addRowAfter =
+ (rowId: string) =>
+ (layout: Layout): Layout => ({
+ rows: insertAfter(layout.rows, rowId, createRow(emptyFile)),
+ })
+
+export const addColumnAfter =
+ (rowId: string, columnId: string) =>
+ (file: FileExtended) =>
+ (layout: Layout): Layout => ({
+ rows: replace(layout.rows, rowId, (row) => ({
+ ...row,
+ columns: insertAfter(row.columns, columnId, createColumn(file)),
+ })),
+ })
+
+export const changeValue =
+ (rowId: string, columnId: string) =>
+ (file: Partial) =>
+ (layout: Layout): Layout => ({
+ rows: replace(layout.rows, rowId, (row) => ({
+ ...row,
+ columns: replace(row.columns, columnId, (column) => ({
+ ...column,
+ file: {
+ ...column.file,
+ ...file,
+ },
+ })),
+ })),
+ })
+
+export const removeColumn =
+ (rowId: string, columnId: string) =>
+ (layout: Layout): Layout => {
+ const rowIndex = layout.rows.findIndex((r) => r.id === rowId)
+ if (layout.rows[rowIndex].columns.length === 1) {
+ return {
+ rows: layout.rows.toSpliced(rowIndex, 1),
+ }
+ }
+ return {
+ rows: replace(layout.rows, rowId, (row) => ({
+ ...row,
+ columns: row.columns.filter((c) => c.id !== columnId),
+ })),
+ }
+ }
+
+function parseColumn(fileOrPath: Summarize.File): Column {
+ if (typeof fileOrPath === 'string') {
+ return createColumn(pathToFile(fileOrPath))
+ }
+ const { types, ...file } = fileOrPath
+ if (!types || !types.length) return createColumn({ ...fileOrPath, isExtended: true })
+ return createColumn({
+ ...file,
+ isExtended: true,
+ type: typeof types[0] === 'string' ? { name: types[0] } : types[0],
+ })
+}
+
+function preStringifyType(type: Summarize.TypeExtended): [Summarize.Type] {
+ const { name, ...rest } = type
+ if (!Object.keys(rest).length) return [name]
+ return [
+ {
+ name,
+ ...rest,
+ },
+ ]
+}
+
+function preStringifyColumn(column: Column): Summarize.File {
+ const {
+ file: { isExtended, type, path, ...file },
+ } = column
+ if (!type) {
+ if (!Object.keys(file).length) return path
+ return {
+ path,
+ ...file,
+ }
+ }
+ return {
+ types: preStringifyType(type),
+ path,
+ ...file,
+ }
+}
+
+function validate(config: any) {
+ const errors = makeSchemaValidator(quiltSummarizeSchema)(config)
+ if (errors.length) {
+ throw errors
+ }
+ return undefined
+}
+
+export function parse(str: string): Layout {
+ const config = JSON.parse(str)
+
+ if (!config) return { rows: [] }
+ if (!Array.isArray(config)) {
+ throw new Error('Expected array')
+ }
+
+ validate(config)
+
+ return {
+ rows: config.map((row) => ({
+ id: crypto.randomUUID(),
+ columns: Array.isArray(row) ? row.map(parseColumn) : [parseColumn(row)],
+ })),
+ }
+}
+
+export function stringify(layout: Layout) {
+ const converted = layout.rows
+ .map((row) => {
+ const columns = row.columns.filter(({ file }) => file.path).map(preStringifyColumn)
+ return columns.length > 1 ? columns : columns[0]
+ })
+ .filter(Boolean)
+
+ validate(converted)
+
+ return JSON.stringify(converted, null, 2)
+}
+
+function useState() {
+ const [layout, setLayout] = React.useState(init())
+ return { layout, setLayout }
+}
+
+export const use = useState
diff --git a/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/__snapshots__/QuiltSummarize.spec.tsx.snap b/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/__snapshots__/QuiltSummarize.spec.tsx.snap
new file mode 100644
index 00000000000..daa46cf88ec
--- /dev/null
+++ b/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/__snapshots__/QuiltSummarize.spec.tsx.snap
@@ -0,0 +1,329 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`QuiltSummarize Render columns 1`] = `
+
+
+
+ Configuration for quilt_summarize.json. See
+
+
+ the docs
+
+
+
+`;
+
+exports[`QuiltSummarize Render empty placeholders 1`] = `
+
+
+
+ Configuration for quilt_summarize.json. See
+
+
+ the docs
+
+
+
+`;
+
+exports[`QuiltSummarize Render row 1`] = `
+
+
+
+ Configuration for quilt_summarize.json. See
+
+
+ the docs
+
+
+
+`;
diff --git a/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/index.ts b/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/index.ts
new file mode 100644
index 00000000000..2eed11706e1
--- /dev/null
+++ b/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/index.ts
@@ -0,0 +1 @@
+export { default } from './QuiltSummarize'
diff --git a/catalog/app/components/FileEditor/QuiltConfigEditor/WorkflowsToolbar.tsx b/catalog/app/components/FileEditor/QuiltConfigEditor/WorkflowsToolbar.tsx
index f58acac5fa4..ae1d0985a30 100644
--- a/catalog/app/components/FileEditor/QuiltConfigEditor/WorkflowsToolbar.tsx
+++ b/catalog/app/components/FileEditor/QuiltConfigEditor/WorkflowsToolbar.tsx
@@ -32,6 +32,7 @@ function SchemaField({
...rest
}: FieldProps & M.TextFieldProps) {
const { urls } = NamedRoutes.use()
+ // TODO: put this into FileEditor/routes
const href = React.useMemo(
() =>
input.value
@@ -290,6 +291,7 @@ function addWorkflow(workflow: WorkflowYaml): (j: JsonRecord) => JsonRecord {
export default function ToolbarWrapper({ columnPath, onChange }: ToolbarWrapperProps) {
const { paths } = NamedRoutes.use()
+ // TODO: RRDom.useParams<{ bucket: string }>() seems enough
const match = useRouteMatch<{ bucket: string }>({ path: paths.bucketFile, exact: true })
const bucket = match?.params?.bucket
diff --git a/catalog/app/components/FileEditor/State.tsx b/catalog/app/components/FileEditor/State.tsx
index 75c57b249fc..e3d1fff1300 100644
--- a/catalog/app/components/FileEditor/State.tsx
+++ b/catalog/app/components/FileEditor/State.tsx
@@ -15,6 +15,7 @@ function useRedirect() {
const history = RRDom.useHistory()
const { urls } = NamedRoutes.use()
const location = RRDom.useLocation()
+ // TODO: put this into FileEditor/routes
const { add, next } = parseSearch(location.search, true)
return React.useCallback(
({ bucket, key, size, version }: Model.S3File) => {
diff --git a/catalog/app/components/FileEditor/__snapshots__/FileEditor.spec.tsx.snap b/catalog/app/components/FileEditor/__snapshots__/FileEditor.spec.tsx.snap
new file mode 100644
index 00000000000..bcac9abf5be
--- /dev/null
+++ b/catalog/app/components/FileEditor/__snapshots__/FileEditor.spec.tsx.snap
@@ -0,0 +1,61 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`components/FileEditor/FileEditor Editor shows Error when loading failed 1`] = `
+
+`;
+
+exports[`components/FileEditor/FileEditor Editor shows Skeleton while loading data 1`] = `
+
+`;
+
+exports[`components/FileEditor/FileEditor Editor shows TextEditor 1`] = `
+
+`;
+
+exports[`components/FileEditor/FileEditor Editor shows an empty TextEditor 1`] = `
+
+`;
+
+exports[`components/FileEditor/FileEditor Editor shows skeleton when loadMode is not resolved yet 1`] = `
+
+`;
diff --git a/catalog/app/components/FileEditor/index.ts b/catalog/app/components/FileEditor/index.ts
index f3beb287509..1d28cca5c07 100644
--- a/catalog/app/components/FileEditor/index.ts
+++ b/catalog/app/components/FileEditor/index.ts
@@ -2,4 +2,5 @@ export * from './Controls'
export * from './CreateFile'
export * from './FileEditor'
export * from './State'
+export * from './routes'
export * from './types'
diff --git a/catalog/app/components/FileEditor/loader.spec.ts b/catalog/app/components/FileEditor/loader.spec.ts
index 6e1934db5be..2f0cb3d7440 100644
--- a/catalog/app/components/FileEditor/loader.spec.ts
+++ b/catalog/app/components/FileEditor/loader.spec.ts
@@ -1,12 +1,37 @@
-import { isSupportedFileType } from './loader'
+import { renderHook } from '@testing-library/react-hooks'
+
+import { detect, isSupportedFileType, loadMode, useWriteData } from './loader'
+
+const putObject = jest.fn(async () => ({ VersionId: 'bar' }))
+
+const headObject = jest.fn(async () => ({ VersionId: 'foo', ContentLength: 999 }))
jest.mock(
- 'constants/config',
+ 'utils/AWS',
jest.fn(() => ({
- apiGatewayEndpoint: '',
+ S3: {
+ use: jest.fn(() => ({
+ putObject: () => ({
+ promise: putObject,
+ }),
+ headObject: () => ({
+ promise: headObject,
+ }),
+ })),
+ },
})),
)
+jest.mock(
+ 'constants/config',
+ jest.fn(() => ({})),
+)
+
+jest.mock(
+ 'brace/mode/json',
+ jest.fn(() => Promise.resolve(undefined)),
+)
+
describe('components/FileEditor/loader', () => {
describe('isSupportedFileType', () => {
it('should return true for supported files', () => {
@@ -36,4 +61,62 @@ describe('components/FileEditor/loader', () => {
expect(isSupportedFileType('s3://bucket/path/file.bam')).toBe(false)
})
})
+
+ describe('detect', () => {
+ it('should detect quilt_summarize.json', () => {
+ expect(detect('quilt_summarize.json').map((x) => x.brace)).toEqual([
+ '__quiltSummarize',
+ 'json',
+ ])
+ expect(detect('nes/ted/quilt_summarize.json').map((x) => x.brace)).toEqual([
+ '__quiltSummarize',
+ 'json',
+ ])
+ })
+ it('should detect bucket preferences config', () => {
+ expect(detect('.quilt/catalog/config.yml').map((x) => x.brace)).toEqual([
+ '__quiltConfig',
+ 'yaml',
+ ])
+ expect(detect('.quilt/catalog/config.yaml').map((x) => x.brace)).toEqual([
+ '__quiltConfig',
+ 'yaml',
+ ])
+ expect(
+ detect('not/in/root/.quilt/catalog/config.yaml').map((x) => x.brace),
+ ).toEqual(['yaml'])
+ })
+ })
+
+ describe('useWriteData', () => {
+ it('rejects when revision is outdated', () => {
+ const { result } = renderHook(() =>
+ useWriteData({ bucket: 'a', key: 'b', version: 'c' }),
+ )
+ return expect(result.current('any')).rejects.toThrow('Revision is outdated')
+ })
+ it('returns new version', () => {
+ const { result } = renderHook(() =>
+ useWriteData({ bucket: 'a', key: 'b', version: 'foo' }),
+ )
+ return expect(result.current('any')).resolves.toEqual({
+ bucket: 'a',
+ key: 'b',
+ size: 999,
+ version: 'bar',
+ })
+ })
+ })
+
+ describe('loadMode', () => {
+ it('throws on the first call and resolves on the second', () => {
+ expect(() => loadMode('json')).toThrow()
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ expect(loadMode('json')).toBe('fulfilled')
+ resolve(null)
+ })
+ })
+ })
+ })
})
diff --git a/catalog/app/components/FileEditor/loader.ts b/catalog/app/components/FileEditor/loader.ts
index feb4cbde3b5..b3f74082144 100644
--- a/catalog/app/components/FileEditor/loader.ts
+++ b/catalog/app/components/FileEditor/loader.ts
@@ -10,13 +10,13 @@ import * as AWS from 'utils/AWS'
import { Mode, EditorInputType } from './types'
-const cache: { [index in Mode]?: Promise | 'fullfilled' } = {}
+const cache: { [index in Mode]?: Promise | 'fulfilled' } = {}
export const loadMode = (mode: Mode) => {
- if (cache[mode] === 'fullfilled') return cache[mode]
+ if (cache[mode] === 'fulfilled') return cache[mode]
if (cache[mode]) throw cache[mode]
cache[mode] = import(`brace/mode/${mode}`).then(() => {
- cache[mode] = 'fullfilled'
+ cache[mode] = 'fulfilled'
})
throw cache[mode]
}
@@ -28,6 +28,12 @@ const typeQuiltConfig: EditorInputType = {
brace: '__quiltConfig',
}
+const isQuiltSummarize = (path: string) => path.endsWith(quiltConfigs.quiltSummarize)
+const typeQuiltSummarize: EditorInputType = {
+ title: 'Edit with config helper',
+ brace: '__quiltSummarize',
+}
+
const isCsv = PreviewUtils.extIn(['.csv', '.tsv', '.tab'])
const typeCsv: EditorInputType = {
brace: 'less',
@@ -59,6 +65,7 @@ const typeNone: EditorInputType = {
export const detect: (path: string) => EditorInputType[] = R.pipe(
PreviewUtils.stripCompression,
R.cond([
+ [isQuiltSummarize, R.always([typeQuiltSummarize, typeJson])],
[isQuiltConfig, R.always([typeQuiltConfig, typeYaml])],
[isCsv, R.always([typeCsv])],
[isJson, R.always([typeJson])],
diff --git a/catalog/app/components/FileEditor/routes.spec.ts b/catalog/app/components/FileEditor/routes.spec.ts
new file mode 100644
index 00000000000..2297d6c2957
--- /dev/null
+++ b/catalog/app/components/FileEditor/routes.spec.ts
@@ -0,0 +1,67 @@
+import { renderHook } from '@testing-library/react-hooks'
+
+import { useParams, editFileInPackage, useEditFileInPackage } from './routes'
+
+const useParamsInternal = jest.fn(
+ () =>
+ ({
+ bucket: 'b',
+ path: '/a/b/c.txt',
+ }) as Record,
+)
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: jest.fn(() => useParamsInternal()),
+ Redirect: jest.fn(() => null),
+}))
+
+const urls = {
+ bucketFile: jest.fn((a, b, c) => `bucketFile(${a}, ${b}, ${JSON.stringify(c)})`),
+ bucketPackageDetail: jest.fn(
+ (a, b, c) => `bucketPackageDetail(${a}, ${b}, ${JSON.stringify(c)})`,
+ ),
+}
+
+jest.mock('utils/NamedRoutes', () => ({
+ ...jest.requireActual('utils/NamedRoutes'),
+ use: jest.fn(() => ({ urls })),
+}))
+
+describe('components/FileEditor/routes', () => {
+ describe('editFileInPackage', () => {
+ it('should create url', () => {
+ expect(
+ editFileInPackage(urls, { bucket: 'bucket', key: 'key' }, 'logicalKey', 'next'),
+ ).toEqual('bucketFile(bucket, key, {"add":"logicalKey","edit":true,"next":"next"})')
+ })
+ })
+
+ describe('useEditFileInPackage', () => {
+ it('should create url with redirect to package', () => {
+ const { result } = renderHook(() =>
+ useEditFileInPackage(
+ { bucket: 'b', name: 'n', hash: 'h' },
+ { bucket: 'b', key: 'k' },
+ 'lk',
+ ),
+ )
+ expect(result.current).toBe(
+ 'bucketFile(b, k, {"add":"lk","edit":true,"next":"bucketPackageDetail(b, n, {\\"action\\":\\"revisePackage\\"})"})',
+ )
+ })
+ })
+
+ describe('useParams', () => {
+ it('should throw error when no bucket', () => {
+ useParamsInternal.mockImplementationOnce(() => ({}))
+ const { result } = renderHook(() => useParams())
+ expect(result.error).toEqual(new Error('`bucket` must be defined'))
+ })
+
+ it('should return initial path', () => {
+ const { result } = renderHook(() => useParams())
+ expect(result.current.initialPath).toEqual('/a/b/')
+ })
+ })
+})
diff --git a/catalog/app/components/FileEditor/routes.ts b/catalog/app/components/FileEditor/routes.ts
new file mode 100644
index 00000000000..e4d74e23f93
--- /dev/null
+++ b/catalog/app/components/FileEditor/routes.ts
@@ -0,0 +1,47 @@
+import invariant from 'invariant'
+import * as RRDom from 'react-router-dom'
+
+import type * as Routes from 'constants/routes'
+import type * as Model from 'model'
+import * as NamedRoutes from 'utils/NamedRoutes'
+import type { PackageHandle } from 'utils/packageHandle'
+import * as s3paths from 'utils/s3paths'
+
+interface RouteMap {
+ bucketFile: Routes.BucketFileArgs
+ bucketPackageDetail: Routes.BucketPackageDetailArgs
+}
+
+export function editFileInPackage(
+ urls: NamedRoutes.Urls,
+ handle: Model.S3.S3ObjectLocation,
+ logicalKey: string,
+ next: string,
+) {
+ return urls.bucketFile(handle.bucket, handle.key, {
+ add: logicalKey,
+ edit: true,
+ next,
+ })
+}
+
+export function useEditFileInPackage(
+ packageHandle: PackageHandle,
+ fileHandle: Model.S3.S3ObjectLocation,
+ logicalKey: string,
+) {
+ const { urls } = NamedRoutes.use()
+ const { bucket, name } = packageHandle
+ const next = urls.bucketPackageDetail(bucket, name, { action: 'revisePackage' })
+ return editFileInPackage(urls, fileHandle, logicalKey, next)
+}
+
+export function useParams() {
+ const { bucket, path } = RRDom.useParams<{
+ bucket: string
+ path: string
+ }>()
+ invariant(bucket, '`bucket` must be defined')
+
+ return { bucket, initialPath: s3paths.getPrefix(path) }
+}
diff --git a/catalog/app/components/FileEditor/types.ts b/catalog/app/components/FileEditor/types.ts
index 5eb8af73c1d..e090bcd63c4 100644
--- a/catalog/app/components/FileEditor/types.ts
+++ b/catalog/app/components/FileEditor/types.ts
@@ -1,4 +1,11 @@
-export type Mode = '__quiltConfig' | 'less' | 'json' | 'markdown' | 'plain_text' | 'yaml'
+export type Mode =
+ | '__quiltConfig'
+ | '__quiltSummarize'
+ | 'less'
+ | 'json'
+ | 'markdown'
+ | 'plain_text'
+ | 'yaml'
export interface EditorInputType {
title?: string
diff --git a/catalog/app/components/Preview/loaders/summarize.ts b/catalog/app/components/Preview/loaders/summarize.ts
index 66839d20817..928e7370413 100644
--- a/catalog/app/components/Preview/loaders/summarize.ts
+++ b/catalog/app/components/Preview/loaders/summarize.ts
@@ -22,6 +22,7 @@ export interface StyleOptions {
export interface PerspectiveOptions {
config?: ViewConfig
+ settings?: boolean
}
interface TypeExtendedEssentials {
@@ -40,6 +41,9 @@ export interface FileExtended {
description?: string
title?: string
types?: Type[]
+
+ expand?: boolean
+ width?: string | number
}
export type File = FileShortcut | FileExtended
diff --git a/catalog/app/constants/quiltConfigs.ts b/catalog/app/constants/quiltConfigs.ts
index 73528437421..db9a2cf8163 100644
--- a/catalog/app/constants/quiltConfigs.ts
+++ b/catalog/app/constants/quiltConfigs.ts
@@ -12,4 +12,6 @@ export const esQueries = '.quilt/queries/config.yaml'
// ]
export const workflows = '.quilt/workflows/config.yml'
+export const quiltSummarize = 'quilt_summarize.json'
+
export const all = [...bucketPreferences, esQueries, workflows]
diff --git a/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx b/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx
index d92390d7a9e..d5ee1f0b91d 100644
--- a/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx
+++ b/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx
@@ -713,17 +713,6 @@ function FileDisplay({
[bucket, history, name, path, hashOrTag, urls],
)
- const handleEdit = React.useCallback(() => {
- const next = urls.bucketPackageDetail(bucket, name, { action: 'revisePackage' })
- const physicalHandle = s3paths.parseS3Url(file.physicalKey)
- const editUrl = urls.bucketFile(physicalHandle.bucket, physicalHandle.key, {
- add: path,
- edit: true,
- next,
- })
- history.push(editUrl)
- }, [file, bucket, history, name, path, urls])
-
const handle: LogicalKeyResolver.S3SummarizeHandle = React.useMemo(
() => ({
...s3paths.parseS3Url(file.physicalKey),
@@ -733,6 +722,9 @@ function FileDisplay({
[file, packageHandle],
)
+ const editUrl = FileEditor.useEditFileInPackage(packageHandle, handle, path)
+ const handleEdit = React.useCallback(() => history.push(editUrl), [editUrl, history])
+
return (
// @ts-expect-error
diff --git a/catalog/app/containers/Bucket/requests/object.spec.ts b/catalog/app/containers/Bucket/requests/object.spec.ts
index 10001dc5e27..037a3e16749 100644
--- a/catalog/app/containers/Bucket/requests/object.spec.ts
+++ b/catalog/app/containers/Bucket/requests/object.spec.ts
@@ -66,11 +66,10 @@ describe('app/containers/Bucket/requests/object', () => {
} as S3.Types.ListObjectVersionsOutput),
}),
}
- it('return object versions', () => {
+ it('return object versions', () =>
expect(
objectVersions({ s3: s3 as S3, bucket: 'any', path: 'foo' }),
- ).resolves.toMatchSnapshot()
- })
+ ).resolves.toMatchSnapshot())
})
describe('fetchFile', () => {
@@ -122,7 +121,7 @@ describe('app/containers/Bucket/requests/object', () => {
s3: s3 as S3,
handle: { bucket: 'b', key: 'does-not-exist' },
})
- expect(result).rejects.toThrow(FileNotFound)
+ return expect(result).rejects.toThrow(FileNotFound)
})
it('re-throws on error', async () => {
@@ -132,7 +131,7 @@ describe('app/containers/Bucket/requests/object', () => {
s3: s3 as S3,
handle: { bucket: 'b', key: 'error' },
})
- expect(result).rejects.toThrow(Error)
+ return expect(result).rejects.toThrow(Error)
})
})
})