diff --git a/.github/workflows/deploy-catalog.yaml b/.github/workflows/deploy-catalog.yaml index fc4f8aed0fe..69ad0c608dc 100644 --- a/.github/workflows/deploy-catalog.yaml +++ b/.github/workflows/deploy-catalog.yaml @@ -4,6 +4,7 @@ on: push: branches: - master + - tabulator-feature-flag # FIXME: revert paths: - '.github/workflows/deploy-catalog.yaml' - 'catalog/**' @@ -64,5 +65,5 @@ jobs: -t $ECR_REGISTRY_MP/$ECR_REPOSITORY_MP:$IMAGE_TAG \ . docker push $ECR_REGISTRY_PROD/$ECR_REPOSITORY:$IMAGE_TAG - docker push $ECR_REGISTRY_GOVCLOUD/$ECR_REPOSITORY:$IMAGE_TAG - docker push $ECR_REGISTRY_MP/$ECR_REPOSITORY_MP:$IMAGE_TAG + # docker push $ECR_REGISTRY_GOVCLOUD/$ECR_REPOSITORY:$IMAGE_TAG + # docker push $ECR_REGISTRY_MP/$ECR_REPOSITORY_MP:$IMAGE_TAG diff --git a/catalog/CHANGELOG.md b/catalog/CHANGELOG.md index 518d7fbc115..546d0b49d6f 100644 --- a/catalog/CHANGELOG.md +++ b/catalog/CHANGELOG.md @@ -18,6 +18,7 @@ where verb is one of ## Changes - [Added] Admin: Tabulator Settings (open query) ([#4255](https://github.com/quiltdata/quilt/pull/4255)) +- [Added] Visual editor for `quilt_summarize.json` ([#4254](https://github.com/quiltdata/quilt/pull/4254)) - [Added] Support "html" type in `quilt_summarize.json` ([#4252](https://github.com/quiltdata/quilt/pull/4252)) - [Fixed] Resolve caching issues where changes in `.quilt/{workflows,catalog}` were not applied ([#4245](https://github.com/quiltdata/quilt/pull/4245)) - [Added] A shortcut to enable adding files to a package from the current bucket ([#4245](https://github.com/quiltdata/quilt/pull/4245)) diff --git a/catalog/app/components/FileEditor/CreateFile.tsx b/catalog/app/components/FileEditor/CreateFile.tsx index c77dbc74a38..dbb1ebac262 100644 --- a/catalog/app/components/FileEditor/CreateFile.tsx +++ b/catalog/app/components/FileEditor/CreateFile.tsx @@ -23,6 +23,7 @@ export function useCreateFileInBucket(bucket: string, path: string) { const { urls } = NamedRoutes.use() const history = RRDom.useHistory() + // TODO: put this into FileEditor/routes const toFile = React.useCallback( (name: string) => urls.bucketFile(bucket, join(path, name), { edit: true }), [bucket, path, urls], @@ -48,6 +49,7 @@ export function useCreateFileInPackage({ bucket, name }: PackageHandle, prefix?: const { urls } = NamedRoutes.use() const history = RRDom.useHistory() + // TODO: put this into FileEditor/routes const toFile = React.useCallback( (fileName: string) => { const next = urls.bucketPackageDetail(bucket, name, { action: 'revisePackage' }) diff --git a/catalog/app/components/FileEditor/FileEditor.spec.tsx b/catalog/app/components/FileEditor/FileEditor.spec.tsx new file mode 100644 index 00000000000..3984417c89d --- /dev/null +++ b/catalog/app/components/FileEditor/FileEditor.spec.tsx @@ -0,0 +1,159 @@ +import * as React from 'react' +import renderer from 'react-test-renderer' +import { renderHook } from '@testing-library/react-hooks' + +import AsyncResult from 'utils/AsyncResult' + +import { useState } from './State' +import { Editor } from './FileEditor' + +jest.mock('utils/AWS', () => ({ S3: { use: () => {} } })) + +jest.mock('./Skeleton', () => () =>
) + +jest.mock('utils/NamedRoutes', () => ({ + ...jest.requireActual('utils/NamedRoutes'), + use: jest.fn(() => ({ urls: {} })), +})) + +jest.mock( + 'react-router-dom', + jest.fn(() => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(() => ({ bucket: 'b', key: 'k' })), + useLocation: jest.fn(() => ({ search: '?edit=true' })), + })), +) + +jest.mock( + 'components/Preview/Display', + jest.fn(() => () =>
), +) + +const getObjectData = jest.fn((cases: any) => + AsyncResult.case(cases, AsyncResult.Ok({ Body: 'body' })), +) + +jest.mock( + 'components/Preview/loaders/utils', + jest.fn(() => ({ + ...jest.requireActual('components/Preview/loaders/utils'), + useObjectGetter: () => ({ + case: getObjectData, + }), + })), +) + +jest.mock( + './TextEditor', + jest.fn(() => ({ initialValue }: { initialValue: string }) => ( +
+ {initialValue} +
+ )), +) + +jest.mock( + 'constants/config', + jest.fn(() => ({})), +) + +const loadMode = jest.fn(() => 'fulfilled') + +jest.mock( + './loader', + jest.fn(() => ({ + loadMode: jest.fn(() => loadMode()), + detect: () => 'text', + useWriteData: () => {}, + })), +) + +describe('components/FileEditor/FileEditor', () => { + describe('Editor', () => { + const handle = { bucket: 'b', key: 'k' } + const hookData = renderHook(() => useState(handle)) + const state = hookData.result.current + it('shows skeleton when loadMode is not resolved yet', () => { + loadMode.mockImplementationOnce(() => { + throw Promise.resolve(null) + }) + const tree = renderer + .create( + , + ) + .toJSON() + expect(tree).toMatchSnapshot() + }) + + it('shows TextEditor', () => { + const tree = renderer + .create( + , + ) + .toJSON() + expect(tree).toMatchSnapshot() + }) + + it('shows an empty TextEditor', () => { + const tree = renderer + .create( + , + ) + .toJSON() + expect(tree).toMatchSnapshot() + }) + + it('shows Skeleton while loading data', () => { + getObjectData.mockImplementationOnce((cases: any) => + AsyncResult.case(cases, AsyncResult.Pending()), + ) + const { result } = renderHook(() => useState(handle)) + const tree = renderer + .create( + , + ) + .toJSON() + expect(tree).toMatchSnapshot() + }) + + it('shows Error when loading failed', () => { + getObjectData.mockImplementationOnce((cases: any) => + AsyncResult.case(cases, AsyncResult.Err(new Error('Fail'))), + ) + const { result } = renderHook(() => useState(handle)) + const tree = renderer + .create( + , + ) + .toJSON() + expect(tree).toMatchSnapshot() + }) + }) +}) diff --git a/catalog/app/components/FileEditor/FileEditor.tsx b/catalog/app/components/FileEditor/FileEditor.tsx index e34851c84db..91024de89b6 100644 --- a/catalog/app/components/FileEditor/FileEditor.tsx +++ b/catalog/app/components/FileEditor/FileEditor.tsx @@ -17,6 +17,8 @@ import { EditorInputType } from './types' export { detect, isSupportedFileType } from './loader' +const QuiltSummarize = React.lazy(() => import('./QuiltConfigEditor/QuiltSummarize')) + interface EditorProps extends EditorState { className: string editing: EditorInputType @@ -26,39 +28,34 @@ interface EditorProps extends EditorState { function EditorSuspended({ className, - saving, + saving: disabled, empty, error, handle, onChange, editing, }: EditorProps) { - const disabled = saving - if (editing.brace !== '__quiltConfig') { + if (editing.brace !== '__quiltConfig' && editing.brace !== '__quiltSummarize') { loadMode(editing.brace || 'plain_text') // TODO: loaders#typeText.brace } const data = PreviewUtils.useObjectGetter(handle, { noAutoFetch: empty }) + const initialProps = { + className, + disabled, + error, + onChange, + initialValue: '', + } if (empty) - return editing.brace === '__quiltConfig' ? ( - - ) : ( - - ) + switch (editing.brace) { + case '__quiltConfig': + return + case '__quiltSummarize': + return + default: + return + } return data.case({ _: () => , Err: ( @@ -70,30 +67,19 @@ function EditorSuspended({
), Ok: (response: { Body: Buffer }) => { - const value = response.Body.toString('utf-8') - if (editing.brace === '__quiltConfig') { - return ( - - ) + const initialValue = response.Body.toString('utf-8') + const props = { + ...initialProps, + initialValue, + } + switch (editing.brace) { + case '__quiltConfig': + return + case '__quiltSummarize': + return + default: + return } - return ( - - ) }, }) } diff --git a/catalog/app/components/FileEditor/HelpLinks.tsx b/catalog/app/components/FileEditor/HelpLinks.tsx index 41cfe1df14f..f04ad67ba40 100644 --- a/catalog/app/components/FileEditor/HelpLinks.tsx +++ b/catalog/app/components/FileEditor/HelpLinks.tsx @@ -17,6 +17,7 @@ import * as NamedRoutes from 'utils/NamedRoutes' import StyledLink from 'utils/StyledLink' import StyledTooltip from 'utils/StyledTooltip' +// TODO: put this into FileEditor/routes function useRouteToEditFile(handle: Model.S3.S3ObjectLocation) { const { urls } = NamedRoutes.use() const { pathname, search } = RRDom.useLocation() diff --git a/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/QuiltSummarize.spec.tsx b/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/QuiltSummarize.spec.tsx new file mode 100644 index 00000000000..721a901f0a8 --- /dev/null +++ b/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/QuiltSummarize.spec.tsx @@ -0,0 +1,118 @@ +import * as React from 'react' +import renderer from 'react-test-renderer' +import { createMuiTheme } from '@material-ui/core' + +import QuiltSummarize from './QuiltSummarize' + +const theme = createMuiTheme() +const noop = () => {} + +jest.mock( + 'constants/config', + jest.fn(() => ({})), +) + +jest.mock( + 'react-router-dom', + jest.fn(() => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(() => ({ bucket: 'b', key: 'k' })), + useLocation: jest.fn(() => ({ search: '?edit=true' })), + })), +) + +jest.mock( + 'utils/GlobalDialogs', + jest.fn(() => ({ + use: () => noop, + })), +) + +jest.mock( + '@material-ui/core', + jest.fn(() => ({ + ...jest.requireActual('@material-ui/core'), + Divider: jest.fn(() =>
), + Button: jest.fn(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), + IconButton: jest.fn(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), + Icon: jest.fn(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), + TextField: jest.fn(({ value }: { value: React.ReactNode }) => ( +
{value}
+ )), + makeStyles: jest.fn((cb: any) => () => { + const classes = typeof cb === 'function' ? cb(theme) : cb + return Object.keys(classes).reduce( + (acc, key) => ({ + [key]: key, + ...acc, + }), + {}, + ) + }), + })), +) + +describe('QuiltSummarize', () => { + it('Render empty placeholders', () => { + const tree = renderer + .create() + .toJSON() + expect(tree).toMatchSnapshot() + }) + + it('Render row', async () => { + const quiltSummarize = `["foo.md"]` + const tree = renderer.create( + , + ) + // Wait until React.useEffect is resolved (it's actually immediately resolved) + await renderer.act( + () => + new Promise((resolve) => { + const t = setInterval(() => { + if (!tree.root.findByProps({ id: 'text-field' }).props.value) { + clearInterval(t) + resolve(undefined) + } + }, 10) + }), + ) + expect(tree.toJSON()).toMatchSnapshot() + }) + + it('Render columns', async () => { + const quiltSummarize = `[["foo.md", "bar.md"]]` + const tree = renderer.create( + , + ) + // Wait until React.useEffect is resolved (it's actually immediately resolved) + await renderer.act( + () => + new Promise((resolve) => { + const t = setInterval(() => { + if (tree.root.findAllByProps({ id: 'text-field' }).length > 1) { + clearInterval(t) + resolve(undefined) + } + }, 10) + }), + ) + expect(tree.toJSON()).toMatchSnapshot() + }) +}) diff --git a/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/QuiltSummarize.tsx b/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/QuiltSummarize.tsx new file mode 100644 index 00000000000..0f1d6ccb97b --- /dev/null +++ b/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltSummarize/QuiltSummarize.tsx @@ -0,0 +1,802 @@ +import { relative } from 'path' +import type { ErrorObject } from 'ajv' + +import cx from 'classnames' +import * as React from 'react' +import { useDebounce } from 'use-debounce' +import * as M from '@material-ui/core' + +import JsonValidationErrors from 'components/JsonValidationErrors' +import type * as Summarize from 'components/Preview/loaders/summarize' +import Skeleton from 'components/Skeleton' +import { docs } from 'constants/urls' +import * as requests from 'containers/Bucket/requests' +import * as Listing from 'containers/Bucket/Listing' +import { useData } from 'utils/Data' +import * as Dialogs from 'utils/GlobalDialogs' +import StyledLink from 'utils/StyledLink' +import type { JsonRecord } from 'utils/types' + +import { useParams } from '../../routes' + +import type { QuiltConfigEditorProps } from '../QuiltConfigEditor' + +import * as State from './State' +import type { Column, FileExtended, Row, Layout } from './State' + +type JsonTextFieldProps = Omit & { + value?: JsonRecord // TODO: validate TypesExtended['config'] + onChange: (v: JsonRecord) => void +} + +function JsonTextField({ helperText, onChange, value, ...props }: JsonTextFieldProps) { + const [str, setStr] = React.useState(JSON.stringify(value) || '{}') + const [error, setError] = React.useState(null) + const handleChange = React.useCallback( + (event) => { + setStr(event.currentTarget.value) + try { + const json = JSON.parse(event.currentTarget.value) + onChange(json) + setError(null) + } catch (err) { + setError(err instanceof Error ? err : new Error(`${err}`)) + } + }, + [onChange], + ) + + return ( + + ) +} + +function useFormattedListing( + r: requests.BucketListingResult, + initialPath: string, +): Listing.Item[] { + return React.useMemo(() => { + const d = r.dirs.map((p) => Listing.Entry.Dir({ key: p })) + const f = r.files.map(Listing.Entry.File) + const prefix = initialPath === r.path ? '' : r.path + return Listing.format([...d, ...f], { bucket: r.bucket, prefix }) + }, [initialPath, r]) +} + +const useFilePickerStyles = M.makeStyles({ + root: { + flexGrow: 1, + }, +}) + +interface FilePickerProps { + initialPath: string + res: requests.BucketListingResult + onCell: (item: Listing.Item) => void +} + +function FilePicker({ initialPath, res, onCell }: FilePickerProps) { + const classes = useFilePickerStyles() + const items = useFormattedListing(res, initialPath) + const CellComponent = React.useCallback( + ({ item, ...props }) => ( +
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 ( +
+
+ + add + +
+
+ ) +} + +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`] = ` +
+
+
+
+
+
+
+
+
+ settings +
+
+
+ foo.md +
+
+
+
+ close +
+
+
+
+
+
+ add +
+
+
+
+
+
+
+
+
+ settings +
+
+
+ bar.md +
+
+
+
+ close +
+
+
+
+
+
+ add +
+
+
+
+
+
+
+
+ add +
+
+
+
+
+

+ Configuration for quilt_summarize.json. See + + + the docs + +

+
+`; + +exports[`QuiltSummarize Render empty placeholders 1`] = ` +
+
+
+
+
+
+
+
+
+ settings +
+
+
+ +
+
+
+
+ close +
+
+
+
+
+
+ add +
+
+
+
+
+
+
+
+ add +
+
+
+
+
+

+ Configuration for quilt_summarize.json. See + + + the docs + +

+
+`; + +exports[`QuiltSummarize Render row 1`] = ` +
+
+
+
+
+
+
+
+
+ settings +
+
+
+ foo.md +
+
+
+
+ close +
+
+
+
+
+
+ add +
+
+
+
+
+
+
+
+ add +
+
+
+
+
+

+ 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`] = ` +
+
+ + body + +
+
+`; + +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/Admin/Settings/TabulatorSettings.tsx b/catalog/app/containers/Admin/Settings/TabulatorSettings.tsx index 13af3b2e48f..ab406002ace 100644 --- a/catalog/app/containers/Admin/Settings/TabulatorSettings.tsx +++ b/catalog/app/containers/Admin/Settings/TabulatorSettings.tsx @@ -9,8 +9,8 @@ import * as Notifications from 'containers/Notifications' import * as GQL from 'utils/GraphQL' import StyledLink from 'utils/StyledLink' -import UNRESTRICTED_QUERY from './gql/TabulatorUnrestricted.generated' -import SET_UNRESTRICTED_MUTATION from './gql/SetTabulatorUnrestricted.generated' +import OPEN_QUERY_QUERY from './gql/TabulatorOpenQuery.generated' +import SET_OPEN_QUERY_MUTATION from './gql/SetTabulatorOpenQuery.generated' interface ToggleProps { checked: boolean @@ -18,15 +18,15 @@ interface ToggleProps { function Toggle({ checked }: ToggleProps) { const { push: notify } = Notifications.use() - const mutate = GQL.useMutation(SET_UNRESTRICTED_MUTATION) - const [mutation, setMutation] = React.useState<{ value: boolean } | null>(null) + const mutate = GQL.useMutation(SET_OPEN_QUERY_MUTATION) + const [mutation, setMutation] = React.useState<{ enabled: boolean } | null>(null) const handleChange = React.useCallback( - async (_event, value: boolean) => { + async (_event, enabled: boolean) => { if (mutation) return - setMutation({ value }) + setMutation({ enabled }) try { - await mutate({ value }) + await mutate({ enabled }) } catch (e) { Sentry.captureException(e) notify(`Failed to update tabulator settings: ${e}`) @@ -42,7 +42,7 @@ function Toggle({ checked }: ToggleProps) { @@ -65,12 +65,12 @@ function Toggle({ checked }: ToggleProps) { } export default function TabulatorSettings() { - const query = GQL.useQuery(UNRESTRICTED_QUERY) + const query = GQL.useQuery(OPEN_QUERY_QUERY) return ( {GQL.fold(query, { - data: ({ admin }) => , + data: ({ admin }) => , fetching: () => ( <> diff --git a/catalog/app/containers/Admin/Settings/gql/SetTabulatorUnrestricted.generated.ts b/catalog/app/containers/Admin/Settings/gql/SetTabulatorOpenQuery.generated.ts similarity index 59% rename from catalog/app/containers/Admin/Settings/gql/SetTabulatorUnrestricted.generated.ts rename to catalog/app/containers/Admin/Settings/gql/SetTabulatorOpenQuery.generated.ts index 6b3303441ce..6f68ab6cc75 100644 --- a/catalog/app/containers/Admin/Settings/gql/SetTabulatorUnrestricted.generated.ts +++ b/catalog/app/containers/Admin/Settings/gql/SetTabulatorOpenQuery.generated.ts @@ -2,22 +2,22 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' import * as Types from '../../../../model/graphql/types.generated' -export type containers_Admin_Settings_gql_SetTabulatorUnrestrictedMutationVariables = +export type containers_Admin_Settings_gql_SetTabulatorOpenQueryMutationVariables = Types.Exact<{ - value: Types.Scalars['Boolean'] + enabled: Types.Scalars['Boolean'] }> -export type containers_Admin_Settings_gql_SetTabulatorUnrestrictedMutation = { +export type containers_Admin_Settings_gql_SetTabulatorOpenQueryMutation = { readonly __typename: 'Mutation' } & { readonly admin: { readonly __typename: 'AdminMutations' } & { - readonly setTabulatorUnrestricted: { - readonly __typename: 'TabulatorUnrestrictedResult' - } & Pick + readonly setTabulatorOpenQuery: { + readonly __typename: 'TabulatorOpenQueryResult' + } & Pick } } -export const containers_Admin_Settings_gql_SetTabulatorUnrestrictedDocument = { +export const containers_Admin_Settings_gql_SetTabulatorOpenQueryDocument = { kind: 'Document', definitions: [ { @@ -25,12 +25,12 @@ export const containers_Admin_Settings_gql_SetTabulatorUnrestrictedDocument = { operation: 'mutation', name: { kind: 'Name', - value: 'containers_Admin_Settings_gql_SetTabulatorUnrestricted', + value: 'containers_Admin_Settings_gql_SetTabulatorOpenQuery', }, variableDefinitions: [ { kind: 'VariableDefinition', - variable: { kind: 'Variable', name: { kind: 'Name', value: 'value' } }, + variable: { kind: 'Variable', name: { kind: 'Name', value: 'enabled' } }, type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } }, @@ -48,12 +48,15 @@ export const containers_Admin_Settings_gql_SetTabulatorUnrestrictedDocument = { selections: [ { kind: 'Field', - name: { kind: 'Name', value: 'setTabulatorUnrestricted' }, + name: { kind: 'Name', value: 'setTabulatorOpenQuery' }, arguments: [ { kind: 'Argument', - name: { kind: 'Name', value: 'value' }, - value: { kind: 'Variable', name: { kind: 'Name', value: 'value' } }, + name: { kind: 'Name', value: 'enabled' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'enabled' }, + }, }, ], selectionSet: { @@ -61,7 +64,7 @@ export const containers_Admin_Settings_gql_SetTabulatorUnrestrictedDocument = { selections: [ { kind: 'Field', - name: { kind: 'Name', value: 'tabulatorUnrestricted' }, + name: { kind: 'Name', value: 'tabulatorOpenQuery' }, }, ], }, @@ -74,8 +77,8 @@ export const containers_Admin_Settings_gql_SetTabulatorUnrestrictedDocument = { }, ], } as unknown as DocumentNode< - containers_Admin_Settings_gql_SetTabulatorUnrestrictedMutation, - containers_Admin_Settings_gql_SetTabulatorUnrestrictedMutationVariables + containers_Admin_Settings_gql_SetTabulatorOpenQueryMutation, + containers_Admin_Settings_gql_SetTabulatorOpenQueryMutationVariables > -export { containers_Admin_Settings_gql_SetTabulatorUnrestrictedDocument as default } +export { containers_Admin_Settings_gql_SetTabulatorOpenQueryDocument as default } diff --git a/catalog/app/containers/Admin/Settings/gql/SetTabulatorOpenQuery.graphql b/catalog/app/containers/Admin/Settings/gql/SetTabulatorOpenQuery.graphql new file mode 100644 index 00000000000..8a7ead43a38 --- /dev/null +++ b/catalog/app/containers/Admin/Settings/gql/SetTabulatorOpenQuery.graphql @@ -0,0 +1,7 @@ +mutation($enabled: Boolean!) { + admin { + setTabulatorOpenQuery(enabled: $enabled) { + tabulatorOpenQuery + } + } +} diff --git a/catalog/app/containers/Admin/Settings/gql/SetTabulatorUnrestricted.graphql b/catalog/app/containers/Admin/Settings/gql/SetTabulatorUnrestricted.graphql deleted file mode 100644 index 800bed27f5a..00000000000 --- a/catalog/app/containers/Admin/Settings/gql/SetTabulatorUnrestricted.graphql +++ /dev/null @@ -1,7 +0,0 @@ -mutation($value: Boolean!) { - admin { - setTabulatorUnrestricted(value: $value) { - tabulatorUnrestricted - } - } -} diff --git a/catalog/app/containers/Admin/Settings/gql/TabulatorUnrestricted.generated.ts b/catalog/app/containers/Admin/Settings/gql/TabulatorOpenQuery.generated.ts similarity index 57% rename from catalog/app/containers/Admin/Settings/gql/TabulatorUnrestricted.generated.ts rename to catalog/app/containers/Admin/Settings/gql/TabulatorOpenQuery.generated.ts index 82b2e82d5a8..8a68745e323 100644 --- a/catalog/app/containers/Admin/Settings/gql/TabulatorUnrestricted.generated.ts +++ b/catalog/app/containers/Admin/Settings/gql/TabulatorOpenQuery.generated.ts @@ -2,28 +2,26 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' import * as Types from '../../../../model/graphql/types.generated' -export type containers_Admin_Settings_gql_TabulatorUnrestrictedQueryVariables = - Types.Exact<{ [key: string]: never }> +export type containers_Admin_Settings_gql_TabulatorOpenQueryQueryVariables = Types.Exact<{ + [key: string]: never +}> -export type containers_Admin_Settings_gql_TabulatorUnrestrictedQuery = { +export type containers_Admin_Settings_gql_TabulatorOpenQueryQuery = { readonly __typename: 'Query' } & { readonly admin: { readonly __typename: 'AdminQueries' } & Pick< Types.AdminQueries, - 'tabulatorUnrestricted' + 'tabulatorOpenQuery' > } -export const containers_Admin_Settings_gql_TabulatorUnrestrictedDocument = { +export const containers_Admin_Settings_gql_TabulatorOpenQueryDocument = { kind: 'Document', definitions: [ { kind: 'OperationDefinition', operation: 'query', - name: { - kind: 'Name', - value: 'containers_Admin_Settings_gql_TabulatorUnrestricted', - }, + name: { kind: 'Name', value: 'containers_Admin_Settings_gql_TabulatorOpenQuery' }, selectionSet: { kind: 'SelectionSet', selections: [ @@ -33,7 +31,7 @@ export const containers_Admin_Settings_gql_TabulatorUnrestrictedDocument = { selectionSet: { kind: 'SelectionSet', selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'tabulatorUnrestricted' } }, + { kind: 'Field', name: { kind: 'Name', value: 'tabulatorOpenQuery' } }, ], }, }, @@ -42,8 +40,8 @@ export const containers_Admin_Settings_gql_TabulatorUnrestrictedDocument = { }, ], } as unknown as DocumentNode< - containers_Admin_Settings_gql_TabulatorUnrestrictedQuery, - containers_Admin_Settings_gql_TabulatorUnrestrictedQueryVariables + containers_Admin_Settings_gql_TabulatorOpenQueryQuery, + containers_Admin_Settings_gql_TabulatorOpenQueryQueryVariables > -export { containers_Admin_Settings_gql_TabulatorUnrestrictedDocument as default } +export { containers_Admin_Settings_gql_TabulatorOpenQueryDocument as default } diff --git a/catalog/app/containers/Admin/Settings/gql/TabulatorOpenQuery.graphql b/catalog/app/containers/Admin/Settings/gql/TabulatorOpenQuery.graphql new file mode 100644 index 00000000000..dd7fd5a7762 --- /dev/null +++ b/catalog/app/containers/Admin/Settings/gql/TabulatorOpenQuery.graphql @@ -0,0 +1,5 @@ +query { + admin { + tabulatorOpenQuery + } +} diff --git a/catalog/app/containers/Admin/Settings/gql/TabulatorUnrestricted.graphql b/catalog/app/containers/Admin/Settings/gql/TabulatorUnrestricted.graphql deleted file mode 100644 index 99d139d415d..00000000000 --- a/catalog/app/containers/Admin/Settings/gql/TabulatorUnrestricted.graphql +++ /dev/null @@ -1,5 +0,0 @@ -query { - admin { - tabulatorUnrestricted - } -} 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) }) }) }) diff --git a/catalog/app/model/graphql/schema.generated.ts b/catalog/app/model/graphql/schema.generated.ts index a4956358946..20ad97f7ab2 100644 --- a/catalog/app/model/graphql/schema.generated.ts +++ b/catalog/app/model/graphql/schema.generated.ts @@ -241,18 +241,18 @@ export default { ], }, { - name: 'setTabulatorUnrestricted', + name: 'setTabulatorOpenQuery', type: { kind: 'NON_NULL', ofType: { kind: 'OBJECT', - name: 'TabulatorUnrestrictedResult', + name: 'TabulatorOpenQueryResult', ofType: null, }, }, args: [ { - name: 'value', + name: 'enabled', type: { kind: 'NON_NULL', ofType: { @@ -309,7 +309,7 @@ export default { args: [], }, { - name: 'tabulatorUnrestricted', + name: 'tabulatorOpenQuery', type: { kind: 'NON_NULL', ofType: { @@ -5475,22 +5475,29 @@ export default { }, { kind: 'OBJECT', - name: 'TabulatorTable', + name: 'TabulatorOpenQueryResult', fields: [ { - name: 'name', + name: 'tabulatorOpenQuery', type: { kind: 'NON_NULL', ofType: { kind: 'SCALAR', - name: 'String', + name: 'Boolean', ofType: null, }, }, args: [], }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'TabulatorTable', + fields: [ { - name: 'config', + name: 'name', type: { kind: 'NON_NULL', ofType: { @@ -5501,20 +5508,13 @@ export default { }, args: [], }, - ], - interfaces: [], - }, - { - kind: 'OBJECT', - name: 'TabulatorUnrestrictedResult', - fields: [ { - name: 'tabulatorUnrestricted', + name: 'config', type: { kind: 'NON_NULL', ofType: { kind: 'SCALAR', - name: 'Boolean', + name: 'String', ofType: null, }, }, diff --git a/catalog/app/model/graphql/types.generated.ts b/catalog/app/model/graphql/types.generated.ts index 15313671476..8b401788ada 100644 --- a/catalog/app/model/graphql/types.generated.ts +++ b/catalog/app/model/graphql/types.generated.ts @@ -48,7 +48,7 @@ export interface AdminMutations { readonly setSsoConfig: Maybe readonly bucketSetTabulatorTable: BucketSetTabulatorTableResult readonly bucketRenameTabulatorTable: BucketSetTabulatorTableResult - readonly setTabulatorUnrestricted: TabulatorUnrestrictedResult + readonly setTabulatorOpenQuery: TabulatorOpenQueryResult } export interface AdminMutationssetSsoConfigArgs { @@ -67,8 +67,8 @@ export interface AdminMutationsbucketRenameTabulatorTableArgs { newTableName: Scalars['String'] } -export interface AdminMutationssetTabulatorUnrestrictedArgs { - value: Scalars['Boolean'] +export interface AdminMutationssetTabulatorOpenQueryArgs { + enabled: Scalars['Boolean'] } export interface AdminQueries { @@ -76,7 +76,7 @@ export interface AdminQueries { readonly user: UserAdminQueries readonly ssoConfig: Maybe readonly isDefaultRoleSettingDisabled: Scalars['Boolean'] - readonly tabulatorUnrestricted: Scalars['Boolean'] + readonly tabulatorOpenQuery: Scalars['Boolean'] } export interface BooleanPackageUserMetaFacet extends IPackageUserMetaFacet { @@ -1173,17 +1173,17 @@ export interface SubscriptionState { export type SwitchRoleResult = Me | InvalidInput | OperationError +export interface TabulatorOpenQueryResult { + readonly __typename: 'TabulatorOpenQueryResult' + readonly tabulatorOpenQuery: Scalars['Boolean'] +} + export interface TabulatorTable { readonly __typename: 'TabulatorTable' readonly name: Scalars['String'] readonly config: Scalars['String'] } -export interface TabulatorUnrestrictedResult { - readonly __typename: 'TabulatorUnrestrictedResult' - readonly tabulatorUnrestricted: Scalars['Boolean'] -} - export interface TestStats { readonly __typename: 'TestStats' readonly passed: Scalars['Int'] diff --git a/catalog/app/utils/GraphQL/Provider.tsx b/catalog/app/utils/GraphQL/Provider.tsx index f6e5e5cc36b..87a36c05b01 100644 --- a/catalog/app/utils/GraphQL/Provider.tsx +++ b/catalog/app/utils/GraphQL/Provider.tsx @@ -347,14 +347,14 @@ export default function GraphQLProvider({ children }: React.PropsWithChildren<{} cache.invalidate({ __typename: 'Query' }, 'admin') cache.invalidate({ __typename: 'Query' }, 'roles') } - if (result.admin?.setTabulatorUnrestricted?.tabulatorUnrestricted != null) { + if (result.admin?.setTabulatorOpenQuery?.tabulatorOpenQuery != null) { cache.updateQuery( - { query: urql.gql`{ admin { tabulatorUnrestricted } }` }, + { query: urql.gql`{ admin { tabulatorOpenQuery } }` }, ({ admin }) => ({ admin: { ...admin, - tabulatorUnrestricted: - result.admin.setTabulatorUnrestricted.tabulatorUnrestricted, + tabulatorOpenQuery: + result.admin.setTabulatorOpenQuery.tabulatorOpenQuery, }, }), ) diff --git a/shared/graphql/schema.graphql b/shared/graphql/schema.graphql index 9292e3acb36..1238025dd74 100644 --- a/shared/graphql/schema.graphql +++ b/shared/graphql/schema.graphql @@ -526,7 +526,7 @@ type AdminQueries { user: UserAdminQueries! ssoConfig: SsoConfig isDefaultRoleSettingDisabled: Boolean! - tabulatorUnrestricted: Boolean! + tabulatorOpenQuery: Boolean! } type MyRole { @@ -905,8 +905,8 @@ union SetSsoConfigResult = SsoConfig | InvalidInput | OperationError union BucketSetTabulatorTableResult = BucketConfig | InvalidInput | OperationError -type TabulatorUnrestrictedResult { - tabulatorUnrestricted: Boolean! +type TabulatorOpenQueryResult { + tabulatorOpenQuery: Boolean! } type AdminMutations { @@ -914,7 +914,7 @@ type AdminMutations { setSsoConfig(config: String): SetSsoConfigResult bucketSetTabulatorTable(bucketName: String!, tableName: String!, config: String): BucketSetTabulatorTableResult! bucketRenameTabulatorTable(bucketName: String!, tableName: String!, newTableName: String!): BucketSetTabulatorTableResult! - setTabulatorUnrestricted(value: Boolean!): TabulatorUnrestrictedResult! + setTabulatorOpenQuery(enabled: Boolean!): TabulatorOpenQueryResult! } type Mutation {