From 823aaa61e4d35aa3b3e155674a1bd36bbe73d55b Mon Sep 17 00:00:00 2001 From: Maksim Chervonnyi Date: Fri, 4 Oct 2024 17:55:26 +0200 Subject: [PATCH 1/2] Tabulator, Admin: UI for modifying tabulator configs (#4135) Co-authored-by: Alexei Mochalov --- catalog/CHANGELOG.md | 1 + .../app/components/FileEditor/FileEditor.tsx | 17 +- .../QuiltConfigEditor/QuiltConfigEditor.tsx | 5 +- .../app/components/FileEditor/Skeleton.tsx | 11 +- .../app/components/FileEditor/TextEditor.tsx | 61 +- .../JsonValidationErrors.tsx | 1 + .../app/containers/Admin/Buckets/Buckets.tsx | 1145 ++++++++--------- .../app/containers/Admin/Buckets/OnDirty.tsx | 48 + .../Admin/Buckets/Tabulator/ConfigEditor.tsx | 60 + .../Admin/Buckets/Tabulator/Tabulator.tsx | 741 +++++++++++ .../Admin/Buckets/Tabulator/index.ts | 1 + .../Buckets/gql/TabulatorTables.generated.ts | 81 ++ .../Admin/Buckets/gql/TabulatorTables.graphql | 9 + .../gql/TabulatorTablesRename.generated.ts | 202 +++ .../Buckets/gql/TabulatorTablesRename.graphql | 27 + .../gql/TabulatorTablesSet.generated.ts | 199 +++ .../Buckets/gql/TabulatorTablesSet.graphql | 27 + .../containers/Admin/Settings/Settings.tsx | 10 +- .../Admin/UsersAndRoles/SsoConfig.tsx | 12 +- catalog/app/containers/Bucket/File.js | 9 +- catalog/app/containers/Bucket/errors.tsx | 2 + .../containers/Notifications/Notification.js | 2 +- catalog/app/model/graphql/schema.generated.ts | 156 +++ catalog/app/model/graphql/types.generated.ts | 23 + catalog/app/utils/GraphQL/Provider.tsx | 1 + catalog/app/utils/error.ts | 26 + catalog/app/utils/json-schema/json-schema.ts | 6 +- catalog/app/utils/yaml.ts | 10 + shared/graphql/schema.graphql | 10 + shared/schemas/tabulatorTable.yml.json | 94 ++ 30 files changed, 2355 insertions(+), 642 deletions(-) create mode 100644 catalog/app/containers/Admin/Buckets/OnDirty.tsx create mode 100644 catalog/app/containers/Admin/Buckets/Tabulator/ConfigEditor.tsx create mode 100644 catalog/app/containers/Admin/Buckets/Tabulator/Tabulator.tsx create mode 100644 catalog/app/containers/Admin/Buckets/Tabulator/index.ts create mode 100644 catalog/app/containers/Admin/Buckets/gql/TabulatorTables.generated.ts create mode 100644 catalog/app/containers/Admin/Buckets/gql/TabulatorTables.graphql create mode 100644 catalog/app/containers/Admin/Buckets/gql/TabulatorTablesRename.generated.ts create mode 100644 catalog/app/containers/Admin/Buckets/gql/TabulatorTablesRename.graphql create mode 100644 catalog/app/containers/Admin/Buckets/gql/TabulatorTablesSet.generated.ts create mode 100644 catalog/app/containers/Admin/Buckets/gql/TabulatorTablesSet.graphql create mode 100644 shared/schemas/tabulatorTable.yml.json diff --git a/catalog/CHANGELOG.md b/catalog/CHANGELOG.md index fb2e90d0025..c3f6f97e8fa 100644 --- a/catalog/CHANGELOG.md +++ b/catalog/CHANGELOG.md @@ -17,6 +17,7 @@ where verb is one of ## Changes +- [Added] Admin: UI for configuring longitudinal queries (Tabulator) ([#4135](https://github.com/quiltdata/quilt/pull/4135)) - [Changed] Admin: Move bucket settings to a separate page ([#4122](https://github.com/quiltdata/quilt/pull/4122)) - [Changed] Athena: always show catalog name, simplify setting execution context ([#4123](https://github.com/quiltdata/quilt/pull/4123)) - [Added] Support `ui.actions.downloadObject` and `ui.actions.downloadPackage` options for configuring visibility of download buttons under "Bucket" and "Packages" respectively ([#4111](https://github.com/quiltdata/quilt/pull/4111)) diff --git a/catalog/app/components/FileEditor/FileEditor.tsx b/catalog/app/components/FileEditor/FileEditor.tsx index 14d6c2bed33..756f2990c41 100644 --- a/catalog/app/components/FileEditor/FileEditor.tsx +++ b/catalog/app/components/FileEditor/FileEditor.tsx @@ -15,12 +15,14 @@ import { EditorInputType } from './types' export { detect, isSupportedFileType } from './loader' interface EditorProps extends EditorState { + className: string editing: EditorInputType empty?: boolean handle: Model.S3.S3ObjectLocation } function EditorSuspended({ + className, saving, empty, error, @@ -37,6 +39,7 @@ function EditorSuspended({ if (empty) return editing.brace === '__quiltConfig' ? ( ) : ( - + ) return data.case({ _: () => , @@ -61,6 +71,7 @@ function EditorSuspended({ if (editing.brace === '__quiltConfig') { return ( ) }, diff --git a/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltConfigEditor.tsx b/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltConfigEditor.tsx index 01a091f0b12..236fb5055cc 100644 --- a/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltConfigEditor.tsx +++ b/catalog/app/components/FileEditor/QuiltConfigEditor/QuiltConfigEditor.tsx @@ -1,4 +1,5 @@ import type { ErrorObject } from 'ajv' +import cx from 'classnames' import * as React from 'react' import * as M from '@material-ui/core' @@ -22,6 +23,7 @@ const useStyles = M.makeStyles((t) => ({ })) export interface QuiltConfigEditorProps { + className?: string disabled?: boolean error: Error | null initialValue?: string @@ -34,6 +36,7 @@ interface QuiltConfigEditorEssentialProps { } export default function QuiltConfigEditorSuspended({ + className, disabled, error, header, @@ -56,7 +59,7 @@ export default function QuiltConfigEditorSuspended({ [onChange, validate], ) return ( -
+
{!!header &&
{header}
} ({ root: { display: 'flex', - height: t.spacing(30), + height: ({ height }: { height: number }) => t.spacing(height), width: '100%', }, lineNumbers: { @@ -16,6 +16,7 @@ const useSkeletonStyles = M.makeStyles((t) => ({ content: { flexGrow: 1, marginLeft: t.spacing(2), + overflow: 'hidden', }, line: { height: t.spacing(2), @@ -25,8 +26,12 @@ const useSkeletonStyles = M.makeStyles((t) => ({ const fakeLines = [80, 50, 100, 60, 30, 80, 50, 100, 60, 30, 20, 70] -export default function Skeleton() { - const classes = useSkeletonStyles() +interface SkeletonProps { + height?: number +} + +export default function Skeleton({ height = 30 }: SkeletonProps) { + const classes = useSkeletonStyles({ height }) return (
diff --git a/catalog/app/components/FileEditor/TextEditor.tsx b/catalog/app/components/FileEditor/TextEditor.tsx index c3eb8872337..c9aded95ec1 100644 --- a/catalog/app/components/FileEditor/TextEditor.tsx +++ b/catalog/app/components/FileEditor/TextEditor.tsx @@ -1,7 +1,7 @@ import * as brace from 'brace' +import cx from 'classnames' import * as React from 'react' import * as M from '@material-ui/core' -import * as Lab from '@material-ui/lab' import Lock from 'components/Lock' @@ -11,33 +11,50 @@ import 'brace/theme/eclipse' const useEditorTextStyles = M.makeStyles((t) => ({ root: { - border: `1px solid ${t.palette.divider}`, - width: '100%', + display: 'flex', + flexDirection: 'column', position: 'relative', + width: '100%', }, editor: { - height: t.spacing(50), + border: `1px solid ${t.palette.divider}`, + flexGrow: 1, resize: 'vertical', }, error: { - marginTop: t.spacing(1), + '& $editor': { + borderColor: t.palette.error.main, + }, + '& $helperText': { + color: t.palette.error.main, + }, + }, + helperText: { + marginTop: t.spacing(0.5), + whiteSpace: 'pre-wrap', // TODO: use JsonValidationErrors }, })) interface TextEditorProps { + autoFocus?: boolean + className: string disabled?: boolean + error: Error | null + leadingChange?: boolean onChange: (value: string) => void type: EditorInputType - value?: string - error: Error | null + initialValue?: string } export default function TextEditor({ - error, + className, + autoFocus, disabled, - type, - value = '', + error, + leadingChange = true, onChange, + type, + initialValue = '', }: TextEditorProps) { const classes = useEditorTextStyles() const ref = React.useRef(null) @@ -53,23 +70,35 @@ export default function TextEditor({ editor.getSession().setMode(`ace/mode/${type.brace}`) editor.setTheme('ace/theme/eclipse') - editor.setValue(value, -1) - onChange(editor.getValue()) // initially fill the value + + editor.$blockScrolling = Infinity + editor.setValue(initialValue, -1) + if (leadingChange) { + // Initially fill the value in the parent component. + // TODO: Re-design fetching data, so leading onChange won't be necessary + // probably, by putting data fetch into FileEditor/State + onChange(editor.getValue()) + } editor.on('change', () => onChange(editor.getValue())) + if (autoFocus) { + editor.focus() + wrapper.scrollIntoView() + } + return () => { resizeObserver.unobserve(wrapper) editor.destroy() } - }, [onChange, ref, type.brace, value]) + }, [autoFocus, leadingChange, onChange, ref, type.brace, initialValue]) return ( -
+
{error && ( - + {error.message} - + )} {disabled && }
diff --git a/catalog/app/components/JsonValidationErrors/JsonValidationErrors.tsx b/catalog/app/components/JsonValidationErrors/JsonValidationErrors.tsx index b1fdd750fac..a666576c6bb 100644 --- a/catalog/app/components/JsonValidationErrors/JsonValidationErrors.tsx +++ b/catalog/app/components/JsonValidationErrors/JsonValidationErrors.tsx @@ -21,6 +21,7 @@ interface SingleErrorProps { error: Err } +// TODO: use more humble layout similar to TextField#helperText function SingleError({ className, error }: SingleErrorProps) { const classes = useSingleErrorStyles() diff --git a/catalog/app/containers/Admin/Buckets/Buckets.tsx b/catalog/app/containers/Admin/Buckets/Buckets.tsx index a21ef812b14..16a16432265 100644 --- a/catalog/app/containers/Admin/Buckets/Buckets.tsx +++ b/catalog/app/containers/Admin/Buckets/Buckets.tsx @@ -13,7 +13,6 @@ import * as Lab from '@material-ui/lab' import * as Buttons from 'components/Buttons' import * as Dialog from 'components/Dialog' -import JsonDisplay from 'components/JsonDisplay' import Skeleton from 'components/Skeleton' import * as Notifications from 'containers/Notifications' import type * as Model from 'model' @@ -23,16 +22,16 @@ import type FormSpec from 'utils/FormSpec' import * as GQL from 'utils/GraphQL' import MetaTitle from 'utils/MetaTitle' import * as NamedRoutes from 'utils/NamedRoutes' -import StyledLink from 'utils/StyledLink' import StyledTooltip from 'utils/StyledTooltip' import assertNever from 'utils/assertNever' import parseSearch from 'utils/parseSearch' -import { formatQuantity } from 'utils/string' import { useTracker } from 'utils/tracking' import * as Types from 'utils/types' import * as validators from 'utils/validators' import * as Form from '../Form' +import * as OnDirty from './OnDirty' +import TabulatorForm from './Tabulator' import ListPage, { ListSkeleton as ListPageSkeleton } from './List' @@ -41,19 +40,22 @@ import ADD_MUTATION from './gql/BucketsAdd.generated' import UPDATE_MUTATION from './gql/BucketsUpdate.generated' import { BucketConfigSelectionFragment as BucketConfig } from './gql/BucketConfigSelection.generated' import CONTENT_INDEXING_SETTINGS_QUERY from './gql/ContentIndexingSettings.generated' +import TABULATOR_TABLES_QUERY from './gql/TabulatorTables.generated' -// TODO: organize skeletons - -const noop = () => {} - -const bucketToFormValues = (bucket: BucketConfig) => ({ +const bucketToPrimaryValues = (bucket: BucketConfig) => ({ title: bucket.title, iconUrl: bucket.iconUrl || '', description: bucket.description || '', +}) + +const bucketToMetadataValues = (bucket: BucketConfig) => ({ relevanceScore: bucket.relevanceScore.toString(), overviewUrl: bucket.overviewUrl || '', tags: (bucket.tags || []).join(', '), linkedData: bucket.linkedData ? JSON.stringify(bucket.linkedData) : '', +}) + +const bucketToIndexingAndNotificationsValues = (bucket: BucketConfig) => ({ enableDeepIndexing: !R.equals(bucket.fileExtensionsToIndex, []) && bucket.indexContentBytes !== 0, fileExtensionsToIndex: (bucket.fileExtensionsToIndex || []).join(', '), @@ -64,94 +66,40 @@ const bucketToFormValues = (bucket: BucketConfig) => ({ ? DO_NOT_SUBSCRIBE_SYM : bucket.snsNotificationArn, skipMetaDataIndexing: bucket.skipMetaDataIndexing ?? false, - browsable: bucket.browsable ?? false, }) -interface CardAvatarProps { - className?: string - src: string -} - -function CardAvatar({ className, src }: CardAvatarProps) { - if (src.startsWith('http')) return - return {src} -} - -const useCardStyles = M.makeStyles((t) => ({ - avatar: { - display: 'block', - }, - header: { - paddingBottom: t.spacing(1), - }, - content: { - paddingTop: 0, - '& > * + *': { - marginTop: t.spacing(1), - }, - }, -})) - -interface CardProps { - children?: React.ReactNode - className?: string - disabled?: boolean - icon?: string | null - onEdit?: () => void - subTitle?: string - title: React.ReactNode -} +const bucketToPreviewValues = (bucket: BucketConfig) => ({ + browsable: bucket.browsable ?? false, +}) -function Card({ - children, - className, - disabled, - icon, - onEdit, - subTitle, - title, -}: CardProps) { - const classes = useCardStyles() - return ( - - - edit - - ) - } - avatar={icon && } - className={classes.header} - subheader={subTitle} - title={title} - /> - {children && {children}} - - ) -} +const bucketToFormValues = (bucket: BucketConfig) => ({ + ...bucketToPrimaryValues(bucket), + ...bucketToMetadataValues(bucket), + ...bucketToIndexingAndNotificationsValues(bucket), + ...bucketToPreviewValues(bucket), +}) -const useFormActionsStyles = M.makeStyles((t) => ({ +const useStickyActionsStyles = M.makeStyles((t) => ({ actions: { animation: `$show 150ms ease-out`, + padding: t.spacing(3, 0, 0), + alignItems: 'center', display: 'flex', justifyContent: 'flex-end', - padding: t.spacing(2, 1), '& > * + *': { // Spacing between direct children marginLeft: t.spacing(2), }, }, - placeholder: { - height: t.spacing(8), - }, sticky: { animation: `$sticking 150ms ease-out`, bottom: 0, left: '50%', position: 'fixed', transform: `translateX(-50%)`, + '& $actions': { + padding: t.spacing(2), + }, }, '@keyframes show': { '0%': { @@ -171,38 +119,50 @@ const useFormActionsStyles = M.makeStyles((t) => ({ }, })) -interface FormActionsProps { +interface StickyActionsProps { children: React.ReactNode - siblingRef: React.RefObject + parentRef: React.RefObject } -// 1. Listen scroll and sibling element resize -// 2. Get the bottom of `` and debounce the value -// 3. If the bottom is below the viewport, make the element `position: "fixed"` -function FormActions({ children, siblingRef }: FormActionsProps) { - const classes = useFormActionsStyles() +function StickyActions({ children, parentRef }: StickyActionsProps) { + const classes = useStickyActionsStyles() - const [bottom, setBottom] = React.useState(0) + const [size, setSize] = React.useState(null) + const [parentSize, setParentSize] = React.useState(null) const ref = React.useRef(null) const handleScroll = React.useCallback(() => { const rect = ref.current?.getBoundingClientRect() if (!rect || !rect.height) return - setBottom(rect.bottom) - }, []) + setSize(rect) + const parent = parentRef.current?.getBoundingClientRect() + if (!parent || !parent.height) return + setParentSize(parent) + }, [parentRef]) React.useEffect(() => { window.addEventListener('scroll', handleScroll) return () => window.removeEventListener('scroll', handleScroll) }, [handleScroll]) - const { height: siblingHeight } = useResizeObserver({ ref: siblingRef }) - React.useEffect(() => handleScroll(), [handleScroll, siblingHeight]) - - const DEBOUNCE_TIMEOUT = 150 - const [debouncedBottom] = useDebounce(bottom, DEBOUNCE_TIMEOUT) - const sticky = React.useMemo( - () => - debouncedBottom >= (window.innerHeight || document.documentElement.clientHeight), - [debouncedBottom], - ) + const { height: parentHeight } = useResizeObserver({ ref: parentRef }) + React.useEffect(() => handleScroll(), [handleScroll, parentHeight]) + + const DEBOUNCE_TIMEOUT = 50 + const [debouncedSize] = useDebounce(size, DEBOUNCE_TIMEOUT) + const [debouncedParentSize] = useDebounce(parentSize, DEBOUNCE_TIMEOUT) + const sticky = React.useMemo(() => { + const winHeight = window.innerHeight || document.documentElement.clientHeight + + const containerBottom = debouncedSize?.bottom || 0 + const containerHeight = debouncedSize?.height || 0 + const parentTop = debouncedParentSize?.top || 0 + + return ( + // Container's bottom (relative to viewport) is below the viewport's bottom + containerBottom >= winHeight + containerHeight && + // Parent's top is inside the viewport + parentTop >= 0 && + parentTop <= winHeight - containerHeight + ) + }, [debouncedSize, debouncedParentSize]) return (
@@ -213,7 +173,7 @@ function FormActions({ children, siblingRef }: FormActionsProps) { {children} -
+
{/* height placeholder */}
) : (
{children}
@@ -222,56 +182,37 @@ function FormActions({ children, siblingRef }: FormActionsProps) { ) } -const useSubPageHeaderStyles = M.makeStyles({ +const useSubPageHeaderStyles = M.makeStyles((t) => ({ root: { + alignItems: 'center', display: 'flex', }, back: { - marginLeft: 'auto', + marginRight: t.spacing(2), }, -}) +})) interface SubPageHeaderProps { back: () => void - children?: React.ReactNode - dirty?: boolean + children: React.ReactNode disabled?: boolean - submit: () => void } -function SubPageHeader({ disabled, back, children, dirty, submit }: SubPageHeaderProps) { +function SubPageHeader({ disabled, back, children }: SubPageHeaderProps) { const classes = useSubPageHeaderStyles() - const handleConfirm = React.useCallback( - (confirmed: boolean) => (confirmed ? submit() : back()), - [back, submit], - ) - const confirm = Dialog.useConfirm({ - cancelTitle: 'Discard', - onSubmit: handleConfirm, - submitTitle: 'Save', - title: 'You have unsaved changes', - }) - const handleBack = React.useCallback( - () => (dirty ? confirm.open() : back()), - [back, confirm, dirty], - ) return (
- {confirm.render(<>)} - {children && ( - - {children} - - )} - arrow_back} > - Back to buckets - + arrow_back + + + {children} +
) } @@ -325,9 +266,21 @@ const usePFSCheckboxStyles = M.makeStyles({ }, }) -function PFSCheckbox({ input, meta }: Form.CheckboxProps & M.CheckboxProps) { +interface PFSCheckboxProps extends Form.CheckboxProps, M.CheckboxProps { + onToggle?: () => void +} + +function PFSCheckbox({ input, meta, onToggle, ...props }: PFSCheckboxProps) { const classes = usePFSCheckboxStyles() - const confirm = React.useCallback((checked) => input?.onChange(checked), [input]) + const confirm = React.useCallback( + (checked) => { + input?.onChange(checked) + if (onToggle) { + onToggle() + } + }, + [input, onToggle], + ) const dialog = Dialog.useConfirm({ submitTitle: 'I agree', title: @@ -335,14 +288,14 @@ function PFSCheckbox({ input, meta }: Form.CheckboxProps & M.CheckboxProps) { onSubmit: confirm, }) const handleCheckbox = React.useCallback( - (event, checked: boolean) => { + (_event, checked: boolean) => { if (checked) { dialog.open() } else { - input?.onChange(checked) + confirm(checked) } }, - [dialog, input], + [dialog, confirm], ) return ( <> @@ -354,12 +307,22 @@ function PFSCheckbox({ input, meta }: Form.CheckboxProps & M.CheckboxProps) { )} + onToggle ? ( + + ) : ( + + ) } label={ <> @@ -374,6 +337,9 @@ function PFSCheckbox({ input, meta }: Form.CheckboxProps & M.CheckboxProps) { } /> + {meta.submitFailed && !!(meta.error || meta.submitError) && ( + {meta.error || meta.submitError} + )} ) } @@ -572,129 +538,89 @@ function Hint({ children }: HintProps) { ) } -const useInlineActionsStyles = M.makeStyles((t) => ({ +const useCardActionsStyles = M.makeStyles((t) => ({ actions: { + alignItems: 'center', display: 'flex', justifyContent: 'flex-end', - padding: t.spacing(2, 0, 0), - '& > * + *': { - // Spacing between direct children - marginLeft: t.spacing(2), - }, }, - error: { + button: { + marginLeft: t.spacing(1), + }, + helper: { flexGrow: 1, }, })) -interface InlineActionsProps { - form: FF.FormApi - onCancel: () => void +interface CardActionsProps { + action?: React.ReactNode + disabled: boolean + form: FF.FormApi } -function InlineActions({ form, onCancel }: InlineActionsProps) { - const classes = useInlineActionsStyles() +function CardActions({ action, disabled, form }: CardActionsProps) { + const { onChange } = OnDirty.use() + const classes = useCardActionsStyles() const state = form.getState() - const handleCancel = React.useCallback(() => { - form.reset() - onCancel() - }, [form, onCancel]) + const { reset, submit } = form + const error = React.useMemo(() => { + if (!state.submitFailed) return + if (state.error || state.submitError) return state.error || state.submitError + // This could happen only if we forgot to handle an error in fields + return `Unhandled error: ${JSON.stringify(state.submitErrors)}` + }, [state]) return ( -
- {state.submitFailed && ( - - )} - {state.submitting && ( - - {() => ( - - - - )} - - )} + <> + + {action} +
+ {error && ( + + )} +
+ {state.submitting && {() => }} form.reset()} + className={classes.button} + onClick={() => reset()} color="primary" - disabled={state.pristine || state.submitting} + disabled={state.pristine || state.submitting || disabled} > Reset - - Cancel - submit()} color="primary" disabled={ state.pristine || state.submitting || - (state.submitFailed && state.hasValidationErrors) + (state.submitFailed && state.hasValidationErrors) || + disabled } variant="contained" > Save -
- ) -} - -const useInlineFormStyles = M.makeStyles((t) => ({ - root: { - padding: t.spacing(2), - }, - title: { - marginBottom: t.spacing(1), - }, -})) - -interface InlineFormProps { - className?: string - title?: string - children: React.ReactNode -} - -function InlineForm({ className, children, title }: InlineFormProps) { - const classes = useInlineFormStyles() - return ( - - {title && ( - - {title} - - )} - {children} - + ) } interface PrimaryFormProps { bucket?: BucketConfig - className?: string - children?: React.ReactNode } -function PrimaryForm({ bucket, children, className }: PrimaryFormProps) { +function PrimaryForm({ bucket }: PrimaryFormProps) { return ( - - {bucket ? ( - - ) : ( + <> + {!bucket && ( )} - {children} - + + ) } -interface PrimaryCardProps { - bucket: BucketConfig +const useCardStyles = M.makeStyles((t) => ({ + root: { + padding: t.spacing(2, 3), + position: 'relative', + }, + disabled: { + position: 'relative', + opacity: 0.3, + '&::after': { + content: '""', + bottom: 0, + cursor: 'not-allowed', + left: 0, + position: 'absolute', + right: 0, + top: 0, + zIndex: 1, + }, + }, + icon: {}, + error: { + outline: `1px solid ${t.palette.error.main}`, + }, + title: { + alignItems: 'center', + display: 'flex', + marginBottom: t.spacing(2), + }, + content: { + // XXX: Fixed in some future MUI versions https://github.com/mui/material-ui/issues/10464 + '& textarea[rows]': { + minHeight: '19px', + }, + }, +})) + +interface CardProps { + children: React.ReactNode className: string - form: FF.FormApi + disabled?: boolean + error?: boolean + title?: React.ReactNode } -function PrimaryCard({ className, bucket, form }: PrimaryCardProps) { - const [editing, setEditing] = React.useState(false) - if (editing) { - return ( - - setEditing(false)} /> - - ) - } +const Card = React.forwardRef(function Card( + { children, className, disabled, error, title }, + ref, +) { + const classes = useCardStyles() return ( - setEditing(true)} - subTitle={`s3://${bucket.name}`} - title={bucket.title} + - {bucket.description && ( - {bucket.description} + {title && ( +
+ {title} +
)} -
+
{children}
+ ) -} +}) -interface MetadataFormProps { - children?: React.ReactNode +type PrimaryFormValues = ReturnType + +interface PrimaryCardProps { + bucket: BucketConfig className: string + disabled: boolean + onSubmit: FF.Config['onSubmit'] +} + +function PrimaryCard({ bucket, className, disabled, onSubmit }: PrimaryCardProps) { + const initialValues = bucketToPrimaryValues(bucket) + const ref = React.useRef(null) + return ( + onSubmit={onSubmit} initialValues={initialValues}> + {({ handleSubmit, form, submitFailed }) => ( + +
+ + + + disabled={disabled} form={form} /> + +
+ )} + + ) } -function MetadataForm({ children, className }: MetadataFormProps) { +function MetadataForm() { return ( - + <> - {children} - + ) } -const useMetadataCardStyles = M.makeStyles((t) => ({ - tagsList: { - marginBottoM: t.spacing(-1), - }, - tag: { - marginBottom: t.spacing(1), - verticalAlign: 'baseline', - '& + &': { - marginLeft: t.spacing(0.5), - }, - }, -})) +type MetadataFormValues = ReturnType interface MetadataCardProps { bucket: BucketConfig className: string - form: FF.FormApi + disabled: boolean + onSubmit: FF.Config['onSubmit'] } -function MetadataCard({ bucket, className, form }: MetadataCardProps) { - const classes = useMetadataCardStyles() - const [editing, setEditing] = React.useState(false) - if (editing) { - return ( - - setEditing(false)} /> - - ) - } +function MetadataCard({ bucket, className, disabled, onSubmit }: MetadataCardProps) { + const initialValues = bucketToMetadataValues(bucket) + const ref = React.useRef(null) return ( - setEditing(true)} - title="Metadata" - > - {bucket.description && ( - {bucket.description} - )} - - Relevance score: {bucket.relevanceScore.toString()} - - {bucket.tags && ( - - Tags:{' '} - {bucket.tags.map((tag) => ( - - ))} - - )} - {bucket.overviewUrl && ( - - Overview URL:{' '} - - {bucket.overviewUrl} - - - )} - {bucket.linkedData && ( - // @ts-expect-error - + onSubmit={onSubmit} initialValues={initialValues}> + {({ handleSubmit, form, submitFailed }) => ( + +
+ + + + disabled={disabled} form={form} /> + +
)} -
+ ) } interface IndexingAndNotificationsFormProps { bucket?: BucketConfig - children?: React.ReactNode - className: string - reindex?: () => void settings: Model.GQLTypes.ContentIndexingSettings } function IndexingAndNotificationsForm({ bucket, - children, - className, - reindex, settings, }: IndexingAndNotificationsFormProps) { const classes = useIndexingAndNotificationsFormStyles() return ( - - {!!reindex && ( - - - Re-index and repair - - - )} - + <> )} - {children} - + ) } @@ -1124,153 +1058,150 @@ const useIndexingAndNotificationsFormStyles = M.makeStyles((t) => ({ }, })) +type IndexingAndNotificationsFormValues = ReturnType< + typeof bucketToIndexingAndNotificationsValues +> + interface IndexingAndNotificationsCardProps { bucket: BucketConfig className: string - form: FF.FormApi - reindex?: () => void + disabled: boolean + onSubmit: FF.Config['onSubmit'] + onReindex: () => void } function IndexingAndNotificationsCard({ bucket, className, - form, - reindex, + disabled, + onSubmit, + onReindex, }: IndexingAndNotificationsCardProps) { - const [editing, setEditing] = React.useState(false) - const data = GQL.useQueryS(CONTENT_INDEXING_SETTINGS_QUERY) const settings = data.config.contentIndexingSettings - if (editing) { - return ( - - setEditing(false)} /> - - ) - } + const initialValues = bucketToIndexingAndNotificationsValues(bucket) + const ref = React.useRef(null) - const { enableDeepIndexing, snsNotificationArn } = bucketToFormValues(bucket) return ( - setEditing(true)} - icon="find_in_page" - title="Indexing and notifications" + + onSubmit={onSubmit} + initialValues={initialValues} > - {!!reindex && ( - ( + - Re-index and repair - - )} - {enableDeepIndexing ? ( - <> - {bucket.fileExtensionsToIndex ? ( - - File extensions to deep index: - {bucket.fileExtensionsToIndex.join(', ')} - - ) : ( - - Default file extensions to deep index: - {settings.extensions.join(', ')} - - )} - {bucket.indexContentBytes ? ( - - Content bytes to deep index is {formatQuantity(bucket.indexContentBytes)}{' '} - bytes - - ) : ( - - Default content bytes to deep index is{' '} - {formatQuantity(settings.bytesDefault)} bytes - - )} - - ) : ( - Deep indexing is disabled - )} - {bucket.scannerParallelShardsDepth && ( - - Scanner parallel shards depth: {bucket.scannerParallelShardsDepth} - - )} - {bucket.skipMetaDataIndexing && ( - Metadata indexing is disabled - )} - {typeof snsNotificationArn === 'string' && ( - - SNS Topic ARN:{' '} - - {bucket.snsNotificationArn} - - +
+ + + + + action={ + + Re-index and repair + + } + disabled={disabled} + form={form} + /> + +
)} - + ) } -interface PreviewFormProps { - children?: React.ReactNode +function PreviewForm() { + return +} + +type PreviewFormValues = ReturnType + +interface PreviewCardProps { + bucket: BucketConfig className: string + disabled: boolean + onSubmit: FF.Config['onSubmit'] } -function PreviewForm({ children, className }: PreviewFormProps) { +function PreviewCard({ bucket, className, disabled, onSubmit }: PreviewCardProps) { + const initialValues = bucketToPreviewValues(bucket) return ( - - - {children} - + onSubmit={onSubmit} initialValues={initialValues}> + {({ handleSubmit, submitting, form, error, submitError, submitFailed }) => ( + +
+ form.submit()} + /> + + +
+ )} + ) } -interface PreviewCardProps { +interface TabulatorCardProps { + bucket: string className: string - bucket: BucketConfig - form: FF.FormApi + disabled: boolean + /** Have to be memoized */ + onDirty: (dirty: boolean) => void + tabulatorTables: Model.GQLTypes.BucketConfig['tabulatorTables'] } -function PreviewCard({ bucket, className, form }: PreviewCardProps) { - const [editing, setEditing] = React.useState(false) - if (editing) { - return ( - - setEditing(false)} /> - - ) - } +function TabulatorCard({ + bucket, + className, + disabled, + onDirty, + tabulatorTables, +}: TabulatorCardProps) { + const { dirty } = OnDirty.use() + React.useEffect(() => onDirty(dirty), [dirty, onDirty]) return ( setEditing(true)} - icon="code" - title={`Permissive HTML rendering is ${bucket.browsable ? 'enabled' : 'disabled'}`} - /> + disabled={disabled} + title="Tabulation (Longitudinal Querying)" + > + + ) } const useStyles = M.makeStyles((t) => ({ card: { - '& + &': { - marginTop: t.spacing(2), + marginTop: t.spacing(2), + '&:first-child': { + marginTop: 0, }, }, + formTitle: { + ...t.typography.subtitle2, + marginBottom: t.spacing(2), + }, error: { flexGrow: 1, }, @@ -1287,35 +1218,14 @@ function AddPageSkeleton({ back }: AddPageSkeletonProps) { const classes = useStyles() const formRef = React.useRef(null) return ( - <> - - Add a bucket - -
- - - - - - - - - - - - - - - - - - - - - - -
- +
+ Add a bucket + + + + + +
) } @@ -1371,11 +1281,15 @@ interface AddProps { function Add({ back, settings, submit }: AddProps) { const classes = useStyles() const onSubmit = React.useCallback( - async (values) => { + async (values, form) => { try { const input = R.applySpec(addFormSpec)(values) const error = await submit(input) - if (!error) return + if (!error) { + form.reset(values) + back() + return + } if (error instanceof Error) throw error return parseResponseError(error) } catch (e) { @@ -1386,9 +1300,13 @@ function Add({ back, settings, submit }: AddProps) { return { [FF.FORM_ERROR]: 'unexpected' } } }, - [submit], + [back, submit], + ) + const scrollingRef = React.useRef(null) + const guardNavigation = React.useCallback( + () => 'You have unsaved changes. Discard changes and leave the page?', + [], ) - const formRef = React.useRef(null) return ( {({ @@ -1401,22 +1319,31 @@ function Add({ back, settings, submit }: AddProps) { hasValidationErrors, }) => ( <> - + + Add a bucket -
- - - - + + + + + + + + + + + + + + + + Longitudinal query configs will be available after creating the bucket + + - + {submitFailed && ( Add - + )}
@@ -1593,11 +1520,15 @@ function Reindex({ bucket, open, close }: ReindexProps) { interface BucketFieldSkeletonProps { className: string - width: number } -function BucketFieldSkeleton({ className, width }: BucketFieldSkeletonProps) { - return } /> +function BucketFieldSkeleton({ className }: BucketFieldSkeletonProps) { + return ( + }> + + + + ) } interface CardsPlaceholderProps { @@ -1608,10 +1539,10 @@ function CardsPlaceholder({ className }: CardsPlaceholderProps) { const classes = useStyles() return (
- - - - + + + +
) } @@ -1624,7 +1555,9 @@ function EditPageSkeleton({ back }: EditPageSkeletonProps) { const classes = useStyles() return ( <> - + + + ) @@ -1640,122 +1573,110 @@ interface EditProps { | Error | undefined > + tabulatorTables: Model.GQLTypes.BucketConfig['tabulatorTables'] } -function Edit({ bucket, back, submit }: EditProps) { +function Edit({ bucket, back, submit, tabulatorTables }: EditProps) { const [reindexOpen, setReindexOpen] = React.useState(false) const openReindex = React.useCallback(() => setReindexOpen(true), []) const closeReindex = React.useCallback(() => setReindexOpen(false), []) + const { push: notify } = Notifications.use() const classes = useStyles() + const [disabled, setDisabled] = React.useState(false) - const onSubmit = React.useCallback( - async (values) => { + type OnSubmit = FF.Config['onSubmit'] & + FF.Config['onSubmit'] & + FF.Config['onSubmit'] & + FF.Config['onSubmit'] + + const onSubmit: OnSubmit = React.useCallback( + async (values, form) => { try { - const input = R.applySpec(editFormSpec)(values) + setDisabled(true) + const input = R.applySpec(editFormSpec)({ + ...bucketToFormValues(bucket), + ...values, + }) const error = await submit(input) - if (!error) return + if (!error) { + notify(`Successfully updated ${bucket.name} bucket`) + form.reset(values) + setDisabled(false) + return + } if (error instanceof Error) throw error + setDisabled(false) return parseResponseError(error) } catch (e) { // eslint-disable-next-line no-console console.error('Error updating bucket') // eslint-disable-next-line no-console console.error(e) + setDisabled(false) return { [FF.FORM_ERROR]: 'unexpected' } } }, - [submit], + [bucket, notify, submit], ) - const initialValues = bucketToFormValues(bucket) - - const formRef = React.useRef(null) + const guardNavigation = () => + 'You have unsaved changes. Discard changes and leave the page?' + const { dirty, onChange } = OnDirty.use() + const onTabulatorDirty = React.useCallback( + (d) => onChange({ modified: { tabulator: true }, dirty: d }), + [onChange], + ) + const scrollingRef = React.useRef(null) return ( - - {({ - handleSubmit, - submitting, - submitFailed, - error, - submitError, - hasValidationErrors, - pristine, - form, - }) => ( - <> - - - }> -
- - - - - - -
- {!bucket && ( - - {submitFailed && ( - - )} - {submitting && ( - - {() => ( - - - - )} - - )} - form.reset()} - color="primary" - disabled={pristine || submitting} - > - Reset - - back('cancel')} - color="primary" - disabled={submitting} - > - Cancel - - - Save - - - )} - - )} -
+ <> + + + + {`s3://${bucket.name}`} + + }> +
+
+ + + + +
+ + + +
+
+ ) } @@ -1776,27 +1697,38 @@ function EditPage({ back }: EditPageProps) { () => (bucketName ? rows.find(({ name }) => name === bucketName) : null), [bucketName, rows], ) + const tabulatorTables = + GQL.useQueryS(TABULATOR_TABLES_QUERY, { bucket: bucketName }).bucketConfig + ?.tabulatorTables || [] const submit = React.useCallback( async (input: Model.GQLTypes.BucketUpdateInput) => { if (!bucket) return new Error('Submit form without bucket') try { const { bucketUpdate: r } = await update({ name: bucket.name, input }) if (r.__typename !== 'BucketUpdateSuccess') { - // TS infered shape but not the actual type + // Generated `InputError` lacks optional properties and not infered correctly return r as Exclude< Model.GQLTypes.BucketUpdateResult, Model.GQLTypes.BucketUpdateSuccess > } - back() } catch (e) { return e instanceof Error ? e : new Error('Error updating bucket') } }, - [back, bucket, update], + [bucket, update], ) if (!bucket) return - return + return ( + + + + ) } interface AddPageProps { @@ -1813,24 +1745,24 @@ function AddPage({ back }: AddPageProps) { async (input: Model.GQLTypes.BucketAddInput) => { try { const { bucketAdd: r } = await add({ input }) - if (r.__typename !== 'BucketAddSuccess') + if (r.__typename !== 'BucketAddSuccess') { // TS infered shape but not the actual type return r as Exclude< Model.GQLTypes.BucketAddResult, Model.GQLTypes.BucketAddSuccess > + } push(`Bucket "${r.bucketConfig.name}" added`) track('WEB', { type: 'admin', action: 'bucket add', bucket: r.bucketConfig.name, }) - back() } catch (e) { return e instanceof Error ? e : new Error('Error adding bucket') } }, - [add, back, push, track], + [add, push, track], ) return } @@ -1865,7 +1797,6 @@ export default function BucketsRouter() { const history = RRDom.useHistory() const { paths, urls } = NamedRoutes.use() const back = React.useCallback(() => history.push(urls.adminBuckets()), [history, urls]) - return ( {['Buckets', 'Admin']} diff --git a/catalog/app/containers/Admin/Buckets/OnDirty.tsx b/catalog/app/containers/Admin/Buckets/OnDirty.tsx new file mode 100644 index 00000000000..37d11d3d31f --- /dev/null +++ b/catalog/app/containers/Admin/Buckets/OnDirty.tsx @@ -0,0 +1,48 @@ +import * as React from 'react' +import * as RF from 'react-final-form' + +export interface SpyState { + dirty: boolean + modified?: Record +} + +interface SpyCallback { + (state: SpyState): void +} + +interface DirtyState { + dirty: boolean + onChange: SpyCallback +} + +const Ctx = React.createContext({ + dirty: false, + onChange: () => { + throw new Error('Not initialized') + }, +}) + +interface ProviderProps { + children: React.ReactNode +} + +export function Provider({ children }: ProviderProps) { + const [count, setCount] = React.useState(0) + const onChange = React.useCallback(({ dirty, modified }: SpyState) => { + if (!modified || Object.values(modified).every((v) => !v)) return + setCount((c) => (dirty ? c + 1 : Math.max(c - 1, 0))) + }, []) + return {children} +} + +export const useOnDirty = () => React.useContext(Ctx) + +export const use = useOnDirty + +interface SpyProps { + onChange: SpyCallback +} + +export function Spy({ onChange }: SpyProps) { + return +} diff --git a/catalog/app/containers/Admin/Buckets/Tabulator/ConfigEditor.tsx b/catalog/app/containers/Admin/Buckets/Tabulator/ConfigEditor.tsx new file mode 100644 index 00000000000..dc0842c874d --- /dev/null +++ b/catalog/app/containers/Admin/Buckets/Tabulator/ConfigEditor.tsx @@ -0,0 +1,60 @@ +import type { ErrorObject } from 'ajv' +import * as React from 'react' +import * as FF from 'final-form' +import * as RF from 'react-final-form' +import * as M from '@material-ui/core' + +import tabulatorTableSchema from 'schemas/tabulatorTable.yml.json' + +import TextEditor from 'components/FileEditor/TextEditor' +import { loadMode } from 'components/FileEditor/loader' +import { JsonInvalidAgainstSchema } from 'utils/error' +import { makeSchemaValidator } from 'utils/json-schema' +import * as yaml from 'utils/yaml' + +const TEXT_EDITOR_TYPE = { brace: 'yaml' as const } + +type ConfigEditorFieldProps = RF.FieldRenderProps & + M.TextFieldProps & { className: string } + +export function ConfigEditor({ errors, input, meta, ...props }: ConfigEditorFieldProps) { + loadMode(TEXT_EDITOR_TYPE.brace) + + const error = meta.error || meta.submitError + const errorMessage = meta.submitFailed && error ? errors[error] || error : undefined + + const [key, setKey] = React.useState(0) + React.useEffect(() => { + // Reset the editor state when the state is reset outside + if (!meta.modified) setKey((k) => k + 1) + }, [meta.modified]) + + return ( + + ) +} + +export const validateTable: FF.FieldValidator = (inputStr?: string) => { + try { + const data = yaml.parse(inputStr) + const validator = makeSchemaValidator(tabulatorTableSchema) + const errors = validator(data) + if (errors.length) { + if (errors[0] instanceof Error) return errors[0].message + return new JsonInvalidAgainstSchema({ errors: errors as ErrorObject[] }).message + } + return undefined + } catch (error) { + // eslint-disable-next-line no-console + console.error(error) + return 'invalid' + } +} diff --git a/catalog/app/containers/Admin/Buckets/Tabulator/Tabulator.tsx b/catalog/app/containers/Admin/Buckets/Tabulator/Tabulator.tsx new file mode 100644 index 00000000000..778d4f4064d --- /dev/null +++ b/catalog/app/containers/Admin/Buckets/Tabulator/Tabulator.tsx @@ -0,0 +1,741 @@ +import cx from 'classnames' +import * as FF from 'final-form' +import * as React from 'react' +import * as RF from 'react-final-form' +import * as M from '@material-ui/core' + +import { useConfirm } from 'components/Dialog' +import TextEditorSkeleton from 'components/FileEditor/Skeleton' +import Skel from 'components/Skeleton' +import * as Notifications from 'containers/Notifications' +import type * as Model from 'model' +import * as GQL from 'utils/GraphQL' +import * as Dialogs from 'utils/GlobalDialogs' +import assertNever from 'utils/assertNever' +import { mkFormError, mapInputErrors } from 'utils/formTools' +import * as validators from 'utils/validators' + +import * as Form from '../../Form' + +import * as OnDirty from '../OnDirty' + +import RENAME_TABULATOR_TABLE_MUTATION from '../gql/TabulatorTablesRename.generated' +import SET_TABULATOR_TABLE_MUTATION from '../gql/TabulatorTablesSet.generated' + +const ConfigEditorModule = () => import('./ConfigEditor') + +const ConfigEditor = React.lazy(() => + ConfigEditorModule().then((m) => ({ default: m.ConfigEditor })), +) + +const defaultConfig = `schema: + - name: column1 # specify the schema + type: Utf8 +source: + type: quilt-packages + package_name: "" # specify a RegEx for matching packages + logical_key: ".*\\\\.csv$" # specify a RegEx for matching logical keys +parser: + format: csv # or parquet +` + +const validateTable: FF.FieldValidator = (...args) => + ConfigEditorModule().then((m) => m.validateTable(...args)) + +const useRenameStyles = M.makeStyles((t) => ({ + button: { + marginLeft: t.spacing(2), + }, +})) + +interface NameFormProps { + close: () => void + submit: (values: FormValuesRenameTable) => Promise + table: Model.GQLTypes.TabulatorTable +} + +const tableToRenameFormData = ({ + name, +}: Model.GQLTypes.TabulatorTable): FormValuesRenameTable => ({ + tableName: name, + newTableName: name, +}) + +function NameForm({ close, submit, table }: NameFormProps) { + const classes = useRenameStyles() + const initialValues = React.useMemo(() => tableToRenameFormData(table), [table]) + const onSubmit = React.useCallback( + async (values: FormValuesRenameTable) => { + const res = await submit(values) + if (!res) close() + return res + }, + [close, submit], + ) + return ( + <> + Edit name of the "{table.name}" table + + {({ + handleSubmit, + hasSubmitErrors, + hasValidationErrors, + modifiedSinceLastSubmit, + pristine, + submitFailed, + submitting, + }) => ( +
+ + + + {{}} + + + + Cancel + + + Rename + + +
+ )} +
+ + ) +} + +const useConfigFormStyles = M.makeStyles((t) => ({ + root: { + display: 'flex', + flexDirection: 'column', + paddingBottom: t.spacing(2), + }, + button: { + marginLeft: 'auto', + '& + &': { + marginLeft: t.spacing(2), + }, + }, + bottom: { + marginTop: t.spacing(1), + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + }, +})) + +interface ConfigFormProps { + className: string + disabled?: boolean + onSubmit: (values: FormValuesSetTable) => void + table: Model.GQLTypes.TabulatorTable +} + +const tableToSetFormData = ({ + name, + config, +}: Model.GQLTypes.TabulatorTable): FormValuesSetTable => ({ + tableName: name, + config, +}) + +function ConfigForm({ className, disabled, onSubmit, table }: ConfigFormProps) { + const classes = useConfigFormStyles() + const { onChange: onFormSpy } = OnDirty.use() + const initialValues = React.useMemo(() => tableToSetFormData(table), [table]) + return ( + + {({ + error, + errors, + form, + handleSubmit, + pristine, + submitError, + submitErrors, + submitFailed, + }) => ( +
+ + + , + validateTable, + )} + disabled={disabled} + autoFocus + /> +
+ {submitFailed && ( + + )} + form.restart()} + size="small" + > + Reset + + + Save + +
+ + )} +
+ ) +} + +interface TableMenuProps { + disabled?: boolean + onDelete: () => void + onRename: () => void +} + +function TableMenu({ disabled, onRename, onDelete }: TableMenuProps) { + const [anchorEl, setAnchorEl] = React.useState(null) + return ( + <> + setAnchorEl(e.currentTarget)} + size="small" + disabled={disabled} + > + more_vert + + setAnchorEl(null)}> + { + setAnchorEl(null) + onRename() + }} + disabled={disabled} + > + Rename + + { + setAnchorEl(null) + onDelete() + }} + disabled={disabled} + > + Delete + + + + ) +} + +interface FormValuesSetTable { + tableName: Model.GQLTypes.TabulatorTable['name'] + config: Model.GQLTypes.TabulatorTable['config'] +} + +interface FormValuesRenameTable { + tableName: Model.GQLTypes.TabulatorTable['name'] + newTableName: Model.GQLTypes.TabulatorTable['name'] +} + +interface FormValuesDeleteTable { + tableName: Model.GQLTypes.TabulatorTable['name'] +} + +const useEmptyStyles = M.makeStyles((t) => ({ + root: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'flex-start', + }, + title: { + marginBottom: t.spacing(2), + }, +})) + +interface EmptyProps { + className: string + onClick: () => void +} + +function Empty({ className, onClick }: EmptyProps) { + const classes = useEmptyStyles() + return ( +
+ + No tables configured + + + Add table + +
+ ) +} + +const useAddTableSkeletonStyles = M.makeStyles((t) => ({ + root: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + }, + name: { + marginBottom: t.spacing(1), + height: t.spacing(3), + }, + buttons: { + display: 'flex', + justifyContent: 'flex-end', + }, + button: { + marginTop: t.spacing(2), + height: t.spacing(4), + width: t.spacing(15), + }, +})) + +function AddTableSkeleton() { + const classes = useAddTableSkeletonStyles() + return ( +
+ + +
+ +
+
+ ) +} + +const useAddTableStyles = M.makeStyles((t) => ({ + root: { + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + button: { + marginLeft: 'auto', + '& + &': { + marginLeft: t.spacing(2), + }, + }, + editor: { + marginBottom: t.spacing(1), + }, + formBottom: { + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + }, +})) + +interface AddTableProps { + disabled?: boolean + onCancel: () => void + onSubmit: (values: FormValuesSetTable) => Promise +} + +function AddTable({ disabled, onCancel, onSubmit }: AddTableProps) { + const classes = useAddTableStyles() + const { onChange: onFormSpy } = OnDirty.use() + return ( + + {({ handleSubmit, error, pristine, submitError, submitFailed }) => ( +
+ + } + variant="outlined" + /> + , + validateTable, + )} + disabled={disabled} + initialValue={defaultConfig} + /> +
+ {submitFailed && ( + + )} + + Cancel + + + Add + +
+ + )} +
+ ) +} + +const useTableStyles = M.makeStyles((t) => ({ + config: { + flexGrow: 1, + }, + name: { + flexGrow: 1, + marginRight: t.spacing(2), + }, + configPlaceholder: { + minHeight: t.spacing(18), + }, +})) + +interface TableProps { + disabled?: boolean + onDelete: (values: FormValuesDeleteTable) => Promise + onRename: (values: FormValuesRenameTable) => Promise + onSubmit: (values: FormValuesSetTable) => Promise + table: Model.GQLTypes.TabulatorTable +} + +function Table({ disabled, onDelete, onRename, onSubmit, table }: TableProps) { + const classes = useTableStyles() + const [open, setOpen] = React.useState(null) + const openDialog = Dialogs.use() + const editName = React.useCallback(() => { + openDialog(({ close }) => ) + }, [onRename, openDialog, table]) + const confirm = useConfirm({ + title: `You are about to delete "${table.name}" table`, + submitTitle: 'Delete', + onSubmit: React.useCallback( + async (confirmed) => { + if (!confirmed) return + const error = await onDelete({ tableName: table.name }) + if (error) { + // eslint-disable-next-line no-console + console.error(error[FF.FORM_ERROR]) + } + }, + [onDelete, table], + ), + }) + + return ( + <> + {confirm.render(<>)} + setOpen((x) => !x)} disabled={disabled}> + + {open ? 'keyboard_arrow_up' : 'keyboard_arrow_down'} + + + + + + + + + {open !== null && ( + }> + + + )} + + + + + ) +} + +// TODO: a way to "redirect" FINAL_FORM error to named field +function parseResponseError( + r: Exclude, + mappings?: Record, +): FF.SubmissionErrors | undefined { + switch (r.__typename) { + case 'InvalidInput': + return mapInputErrors(r.errors, mappings) + case 'OperationError': + return mkFormError(r.message) + default: + return assertNever(r) + } +} + +const useTablesStyles = M.makeStyles((t) => ({ + textPlaceholder: { + height: t.spacing(3.5), + }, +})) + +interface TablesProps { + adding: boolean + bucketName: string + onAdding: (v: boolean) => void + tables: Model.GQLTypes.BucketConfig['tabulatorTables'] +} + +function Tables({ adding, bucketName, onAdding, tables }: TablesProps) { + const classes = useTablesStyles() + + const renameTable = GQL.useMutation(RENAME_TABULATOR_TABLE_MUTATION) + const setTable = GQL.useMutation(SET_TABULATOR_TABLE_MUTATION) + const { push: notify } = Notifications.use() + + const [submitting, setSubmitting] = React.useState(false) + + const onDelete = React.useCallback( + async ({ + tableName, + }: FormValuesDeleteTable): Promise => { + try { + setSubmitting(true) + const response = await setTable({ bucketName, tableName, config: null }) + // Generated `InputError` lacks optional properties and not infered correctly + const r = response.admin + .bucketSetTabulatorTable as Model.GQLTypes.BucketSetTabulatorTableResult + setSubmitting(false) + if (r.__typename === 'BucketConfig') { + notify(`Successfully deleted ${tableName} table`) + return undefined + } + // @ts-expect-error + notify(`Failed to remove ${tableName} table`, { ttl: null }) + return parseResponseError(r) + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error deleting table') + // eslint-disable-next-line no-console + console.error(e) + setSubmitting(false) + // @ts-expect-error + notify(`Failed to remove ${tableName} table`, { ttl: null }) + return mkFormError('unexpected') + } + }, + [bucketName, notify, setTable], + ) + + const onRename = React.useCallback( + async (values: FormValuesRenameTable): Promise => { + try { + setSubmitting(true) + const response = await renameTable({ + bucketName, + ...values, + }) + const r = response.admin + .bucketRenameTabulatorTable as Model.GQLTypes.BucketSetTabulatorTableResult + setSubmitting(false) + if (r.__typename === 'BucketConfig') { + notify(`Successfully updated ${values.tableName} table`) + return undefined + } + return parseResponseError(r, { + newTableName: 'newTableName', + tableName: 'newTableName', + }) + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error updating table') + // eslint-disable-next-line no-console + console.error(e) + setSubmitting(false) + return mkFormError('unexpected') + } + }, + [bucketName, notify, renameTable], + ) + + const onSubmit = React.useCallback( + async (values: FormValuesSetTable): Promise => { + try { + setSubmitting(true) + const response = await setTable({ bucketName, ...values }) + const r = response.admin + .bucketSetTabulatorTable as Model.GQLTypes.BucketSetTabulatorTableResult + setSubmitting(false) + if (r.__typename === 'BucketConfig') { + notify(`Successfully updated ${values.tableName} table`) + return undefined + } + return parseResponseError(r, { + config: 'config', + tableName: 'tableName', + }) + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error creating table') + // eslint-disable-next-line no-console + console.error(e) + setSubmitting(false) + return mkFormError('unexpected') + } + }, + [bucketName, notify, setTable], + ) + + const onSubmitNew = React.useCallback( + async (values: FormValuesSetTable): Promise => { + const error = await onSubmit(values) + if (!error) { + onAdding(false) + } + return error + }, + [onSubmit, onAdding], + ) + + return ( + + {tables.map((table) => ( + + ))} + {adding ? ( + + }> + onAdding(false)} + onSubmit={onSubmitNew} + /> + + + ) : ( + + } /> + + onAdding(true)} type="button"> + Add table + + + + )} + + ) +} + +const useStyles = M.makeStyles((t) => ({ + empty: { + paddingBottom: t.spacing(2), + }, +})) + +interface TabulatorProps { + bucket: string + tables: Model.GQLTypes.BucketConfig['tabulatorTables'] +} + +/** Have to be suspended because of `` and `loadMode(...)` */ +export default function Tabulator({ bucket: bucketName, tables }: TabulatorProps) { + const classes = useStyles() + const [adding, setAdding] = React.useState(false) + + if (!tables.length && !adding) { + return setAdding(true)} /> + } + + return ( + + ) +} diff --git a/catalog/app/containers/Admin/Buckets/Tabulator/index.ts b/catalog/app/containers/Admin/Buckets/Tabulator/index.ts new file mode 100644 index 00000000000..1466f7fd10f --- /dev/null +++ b/catalog/app/containers/Admin/Buckets/Tabulator/index.ts @@ -0,0 +1 @@ +export { default } from './Tabulator' diff --git a/catalog/app/containers/Admin/Buckets/gql/TabulatorTables.generated.ts b/catalog/app/containers/Admin/Buckets/gql/TabulatorTables.generated.ts new file mode 100644 index 00000000000..d9efd1ef83b --- /dev/null +++ b/catalog/app/containers/Admin/Buckets/gql/TabulatorTables.generated.ts @@ -0,0 +1,81 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' +import * as Types from '../../../../model/graphql/types.generated' + +export type containers_Admin_Buckets_gql_TabulatorTablesQueryVariables = Types.Exact<{ + bucket: Types.Scalars['String'] +}> + +export type containers_Admin_Buckets_gql_TabulatorTablesQuery = { + readonly __typename: 'Query' +} & { + readonly bucketConfig: Types.Maybe< + { readonly __typename: 'BucketConfig' } & Pick & { + readonly tabulatorTables: ReadonlyArray< + { readonly __typename: 'TabulatorTable' } & Pick< + Types.TabulatorTable, + 'name' | 'config' + > + > + } + > +} + +export const containers_Admin_Buckets_gql_TabulatorTablesDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'containers_Admin_Buckets_gql_TabulatorTables' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'bucket' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'bucketConfig' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'name' }, + value: { kind: 'Variable', name: { kind: 'Name', value: 'bucket' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'tabulatorTables' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'config' } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + containers_Admin_Buckets_gql_TabulatorTablesQuery, + containers_Admin_Buckets_gql_TabulatorTablesQueryVariables +> + +export { containers_Admin_Buckets_gql_TabulatorTablesDocument as default } diff --git a/catalog/app/containers/Admin/Buckets/gql/TabulatorTables.graphql b/catalog/app/containers/Admin/Buckets/gql/TabulatorTables.graphql new file mode 100644 index 00000000000..3c9feb3754e --- /dev/null +++ b/catalog/app/containers/Admin/Buckets/gql/TabulatorTables.graphql @@ -0,0 +1,9 @@ +query ($bucket: String!) { + bucketConfig(name: $bucket) { + name + tabulatorTables { + name + config + } + } +} diff --git a/catalog/app/containers/Admin/Buckets/gql/TabulatorTablesRename.generated.ts b/catalog/app/containers/Admin/Buckets/gql/TabulatorTablesRename.generated.ts new file mode 100644 index 00000000000..bf67cc32311 --- /dev/null +++ b/catalog/app/containers/Admin/Buckets/gql/TabulatorTablesRename.generated.ts @@ -0,0 +1,202 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' +import * as Types from '../../../../model/graphql/types.generated' + +export type containers_Admin_Buckets_gql_TabulatorTablesRenameMutationVariables = + Types.Exact<{ + bucketName: Types.Scalars['String'] + tableName: Types.Scalars['String'] + newTableName: Types.Scalars['String'] + }> + +export type containers_Admin_Buckets_gql_TabulatorTablesRenameMutation = { + readonly __typename: 'Mutation' +} & { + readonly admin: { readonly __typename: 'AdminMutations' } & { + readonly bucketRenameTabulatorTable: + | ({ readonly __typename: 'BucketConfig' } & Pick & { + readonly tabulatorTables: ReadonlyArray< + { readonly __typename: 'TabulatorTable' } & Pick< + Types.TabulatorTable, + 'name' | 'config' + > + > + }) + | ({ readonly __typename: 'InvalidInput' } & { + readonly errors: ReadonlyArray< + { readonly __typename: 'InputError' } & Pick< + Types.InputError, + 'path' | 'message' + > + > + }) + | ({ readonly __typename: 'OperationError' } & Pick< + Types.OperationError, + 'message' + >) + } +} + +export const containers_Admin_Buckets_gql_TabulatorTablesRenameDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'containers_Admin_Buckets_gql_TabulatorTablesRename' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'bucketName' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'tableName' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'newTableName' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'admin' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'bucketRenameTabulatorTable' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'bucketName' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'bucketName' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'tableName' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'tableName' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'newTableName' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'newTableName' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, + { + kind: 'InlineFragment', + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'BucketConfig' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'tabulatorTables' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'name' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'config' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'InlineFragment', + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'InvalidInput' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'errors' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'path' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'message' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'InlineFragment', + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'OperationError' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'message' } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + containers_Admin_Buckets_gql_TabulatorTablesRenameMutation, + containers_Admin_Buckets_gql_TabulatorTablesRenameMutationVariables +> + +export { containers_Admin_Buckets_gql_TabulatorTablesRenameDocument as default } diff --git a/catalog/app/containers/Admin/Buckets/gql/TabulatorTablesRename.graphql b/catalog/app/containers/Admin/Buckets/gql/TabulatorTablesRename.graphql new file mode 100644 index 00000000000..d578d38ea62 --- /dev/null +++ b/catalog/app/containers/Admin/Buckets/gql/TabulatorTablesRename.graphql @@ -0,0 +1,27 @@ +mutation ($bucketName: String!, $tableName: String!, $newTableName: String!) { + admin { + bucketRenameTabulatorTable( + bucketName: $bucketName + tableName: $tableName + newTableName: $newTableName + ) { + __typename + ... on BucketConfig { + name + tabulatorTables { + name + config + } + } + ... on InvalidInput { + errors { + path + message + } + } + ... on OperationError { + message + } + } + } +} diff --git a/catalog/app/containers/Admin/Buckets/gql/TabulatorTablesSet.generated.ts b/catalog/app/containers/Admin/Buckets/gql/TabulatorTablesSet.generated.ts new file mode 100644 index 00000000000..0cd95207f8d --- /dev/null +++ b/catalog/app/containers/Admin/Buckets/gql/TabulatorTablesSet.generated.ts @@ -0,0 +1,199 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' +import * as Types from '../../../../model/graphql/types.generated' + +export type containers_Admin_Buckets_gql_TabulatorTablesSetMutationVariables = + Types.Exact<{ + bucketName: Types.Scalars['String'] + tableName: Types.Scalars['String'] + config: Types.Maybe + }> + +export type containers_Admin_Buckets_gql_TabulatorTablesSetMutation = { + readonly __typename: 'Mutation' +} & { + readonly admin: { readonly __typename: 'AdminMutations' } & { + readonly bucketSetTabulatorTable: + | ({ readonly __typename: 'BucketConfig' } & Pick & { + readonly tabulatorTables: ReadonlyArray< + { readonly __typename: 'TabulatorTable' } & Pick< + Types.TabulatorTable, + 'name' | 'config' + > + > + }) + | ({ readonly __typename: 'InvalidInput' } & { + readonly errors: ReadonlyArray< + { readonly __typename: 'InputError' } & Pick< + Types.InputError, + 'path' | 'message' + > + > + }) + | ({ readonly __typename: 'OperationError' } & Pick< + Types.OperationError, + 'message' + >) + } +} + +export const containers_Admin_Buckets_gql_TabulatorTablesSetDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'containers_Admin_Buckets_gql_TabulatorTablesSet' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'bucketName' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'tableName' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'config' } }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'admin' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'bucketSetTabulatorTable' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'bucketName' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'bucketName' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'tableName' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'tableName' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'config' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'config' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, + { + kind: 'InlineFragment', + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'BucketConfig' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'tabulatorTables' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'name' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'config' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'InlineFragment', + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'InvalidInput' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'errors' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'path' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'message' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'InlineFragment', + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'OperationError' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'message' } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + containers_Admin_Buckets_gql_TabulatorTablesSetMutation, + containers_Admin_Buckets_gql_TabulatorTablesSetMutationVariables +> + +export { containers_Admin_Buckets_gql_TabulatorTablesSetDocument as default } diff --git a/catalog/app/containers/Admin/Buckets/gql/TabulatorTablesSet.graphql b/catalog/app/containers/Admin/Buckets/gql/TabulatorTablesSet.graphql new file mode 100644 index 00000000000..7c92c02768a --- /dev/null +++ b/catalog/app/containers/Admin/Buckets/gql/TabulatorTablesSet.graphql @@ -0,0 +1,27 @@ +mutation ($bucketName: String!, $tableName: String!, $config: String) { + admin { + bucketSetTabulatorTable( + bucketName: $bucketName + tableName: $tableName + config: $config + ) { + __typename + ... on BucketConfig { + name + tabulatorTables { + name + config + } + } + ... on InvalidInput { + errors { + path + message + } + } + ... on OperationError { + message + } + } + } +} diff --git a/catalog/app/containers/Admin/Settings/Settings.tsx b/catalog/app/containers/Admin/Settings/Settings.tsx index 3ba49b6c1db..0dc05e0265d 100644 --- a/catalog/app/containers/Admin/Settings/Settings.tsx +++ b/catalog/app/containers/Admin/Settings/Settings.tsx @@ -281,13 +281,13 @@ export default function Settings() { return (
{['Settings', 'Admin']} - + Catalog Customization - + Navbar link }> @@ -297,7 +297,7 @@ export default function Settings() { - + Theme (logo and color) }> @@ -307,7 +307,7 @@ export default function Settings() { - + Default search mode }> @@ -317,7 +317,7 @@ export default function Settings() { - + Enable beta features diff --git a/catalog/app/containers/Admin/UsersAndRoles/SsoConfig.tsx b/catalog/app/containers/Admin/UsersAndRoles/SsoConfig.tsx index d5655337836..b740034202e 100644 --- a/catalog/app/containers/Admin/UsersAndRoles/SsoConfig.tsx +++ b/catalog/app/containers/Admin/UsersAndRoles/SsoConfig.tsx @@ -30,20 +30,22 @@ const FORM_ERRORS = { unexpected: 'Unable to update SSO config: something went wrong', } -type TextFieldProps = RF.FieldRenderProps & M.TextFieldProps +type TextFieldProps = RF.FieldRenderProps & + M.TextFieldProps & { className: string } const TEXT_EDITOR_TYPE = { brace: 'yaml' as const } -function TextField({ errors, input, meta }: TextFieldProps) { +function TextField({ className, errors, input, meta }: TextFieldProps) { // TODO: lint yaml const error = meta.error || meta.submitError const errorMessage = meta.submitFailed && error ? errors[error] || error : undefined return ( ) } @@ -57,6 +59,9 @@ const useStyles = M.makeStyles((t) => ({ background: t.palette.error.main, }, }, + editor: { + minHeight: t.spacing(30), + }, error: { marginTop: t.spacing(2), }, @@ -109,6 +114,7 @@ function Form({ label="SSO config" name="config" validate={validators.required as FF.FieldValidator} + className={classes.editor} /> {submitFailed && ( <> diff --git a/catalog/app/containers/Bucket/File.js b/catalog/app/containers/Bucket/File.js index 9442d52903c..d665118fc6f 100644 --- a/catalog/app/containers/Bucket/File.js +++ b/catalog/app/containers/Bucket/File.js @@ -304,6 +304,9 @@ const useStyles = M.makeStyles((t) => ({ maxWidth: 'calc(100% - 40px)', }, }, + editor: { + minHeight: t.spacing(50), + }, topBar: { alignItems: 'flex-end', display: 'flex', @@ -532,7 +535,11 @@ export default function File() { )} {editorState.editing ? (
- +
) : (
diff --git a/catalog/app/containers/Bucket/errors.tsx b/catalog/app/containers/Bucket/errors.tsx index f88c6dba912..fd45d3399ca 100644 --- a/catalog/app/containers/Bucket/errors.tsx +++ b/catalog/app/containers/Bucket/errors.tsx @@ -33,6 +33,7 @@ export interface BucketPreferencesInvalidProps { errors: { instancePath?: string; message?: string }[] } +// TODO: re-use JsonInvalidAgainstSchema export class BucketPreferencesInvalid extends BucketError { static displayName = 'BucketPreferencesInvalid' @@ -46,6 +47,7 @@ export class BucketPreferencesInvalid extends BucketError { } } +// TODO: re-use JsonInvalidAgainstSchema export interface WorkflowsConfigInvalidProps { errors: { instancePath?: string; message?: string }[] } diff --git a/catalog/app/containers/Notifications/Notification.js b/catalog/app/containers/Notifications/Notification.js index 1bf65dd4672..a8ead6821f5 100644 --- a/catalog/app/containers/Notifications/Notification.js +++ b/catalog/app/containers/Notifications/Notification.js @@ -43,7 +43,7 @@ export default function Notification({ id, ttl, message, action, dismiss }) { Notification.propTypes = { id: PT.string.isRequired, - ttl: PT.number.isRequired, + ttl: PT.oneOf([null, PT.number.isRequired]), message: PT.node.isRequired, action: PT.shape({ label: PT.string.isRequired, diff --git a/catalog/app/model/graphql/schema.generated.ts b/catalog/app/model/graphql/schema.generated.ts index 7177acc5c80..e35365e73b6 100644 --- a/catalog/app/model/graphql/schema.generated.ts +++ b/catalog/app/model/graphql/schema.generated.ts @@ -116,6 +116,95 @@ export default { }, ], }, + { + name: 'bucketSetTabulatorTable', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'UNION', + name: 'BucketSetTabulatorTableResult', + ofType: null, + }, + }, + args: [ + { + name: 'bucketName', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + }, + { + name: 'tableName', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + }, + { + name: 'config', + type: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + ], + }, + { + name: 'bucketRenameTabulatorTable', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'UNION', + name: 'BucketSetTabulatorTableResult', + ofType: null, + }, + }, + args: [ + { + name: 'bucketName', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + }, + { + name: 'tableName', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + }, + { + name: 'newTableName', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + }, + ], + }, ], interfaces: [], }, @@ -574,6 +663,24 @@ export default { }, args: [], }, + { + name: 'tabulatorTables', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'LIST', + ofType: { + kind: 'NON_NULL', + ofType: { + kind: 'OBJECT', + name: 'TabulatorTable', + ofType: null, + }, + }, + }, + }, + args: [], + }, ], interfaces: [], }, @@ -715,6 +822,24 @@ export default { ], interfaces: [], }, + { + kind: 'UNION', + name: 'BucketSetTabulatorTableResult', + possibleTypes: [ + { + kind: 'OBJECT', + name: 'BucketConfig', + }, + { + kind: 'OBJECT', + name: 'InvalidInput', + }, + { + kind: 'OBJECT', + name: 'OperationError', + }, + ], + }, { kind: 'UNION', name: 'BucketUpdateResult', @@ -5151,6 +5276,37 @@ export default { }, ], }, + { + kind: 'OBJECT', + name: 'TabulatorTable', + fields: [ + { + name: 'name', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + args: [], + }, + { + name: 'config', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + args: [], + }, + ], + interfaces: [], + }, { kind: 'OBJECT', name: 'TestStats', diff --git a/catalog/app/model/graphql/types.generated.ts b/catalog/app/model/graphql/types.generated.ts index 14d2d201991..edc5d8ab13d 100644 --- a/catalog/app/model/graphql/types.generated.ts +++ b/catalog/app/model/graphql/types.generated.ts @@ -40,12 +40,26 @@ export interface AdminMutations { readonly __typename: 'AdminMutations' readonly user: UserAdminMutations readonly setSsoConfig: Maybe + readonly bucketSetTabulatorTable: BucketSetTabulatorTableResult + readonly bucketRenameTabulatorTable: BucketSetTabulatorTableResult } export interface AdminMutationssetSsoConfigArgs { config: Maybe } +export interface AdminMutationsbucketSetTabulatorTableArgs { + bucketName: Scalars['String'] + tableName: Scalars['String'] + config: Maybe +} + +export interface AdminMutationsbucketRenameTabulatorTableArgs { + bucketName: Scalars['String'] + tableName: Scalars['String'] + newTableName: Scalars['String'] +} + export interface AdminQueries { readonly __typename: 'AdminQueries' readonly user: UserAdminQueries @@ -135,6 +149,7 @@ export interface BucketConfig { readonly associatedPolicies: ReadonlyArray readonly associatedRoles: ReadonlyArray readonly collaborators: ReadonlyArray + readonly tabulatorTables: ReadonlyArray } export interface BucketDoesNotExist { @@ -174,6 +189,8 @@ export interface BucketRemoveSuccess { readonly _: Maybe } +export type BucketSetTabulatorTableResult = BucketConfig | InvalidInput | OperationError + export interface BucketUpdateInput { readonly title: Scalars['String'] readonly iconUrl: Maybe @@ -1120,6 +1137,12 @@ export interface SubscriptionState { export type SwitchRoleResult = Me | InvalidInput | OperationError +export interface TabulatorTable { + readonly __typename: 'TabulatorTable' + readonly name: Scalars['String'] + readonly config: Scalars['String'] +} + 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 500bdd62983..592b71e58e6 100644 --- a/catalog/app/utils/GraphQL/Provider.tsx +++ b/catalog/app/utils/GraphQL/Provider.tsx @@ -142,6 +142,7 @@ export default function GraphQLProvider({ children }: React.PropsWithChildren<{} AdminMutations: () => null, UserAdminMutations: () => null, MutateUserAdminMutations: () => null, + TabulatorTable: (t) => t.name as string, }, updates: { Mutation: { diff --git a/catalog/app/utils/error.ts b/catalog/app/utils/error.ts index 1da7bb5ad4e..8a1e1b088ec 100644 --- a/catalog/app/utils/error.ts +++ b/catalog/app/utils/error.ts @@ -1,3 +1,4 @@ +import type { ErrorObject } from 'ajv' /** * Extensible error class. */ @@ -40,3 +41,28 @@ export class ErrorDisplay extends BaseError { super(headline, { headline, detail, object }) } } + +export interface JsonInvalidAgainstSchemaProps { + errors: ErrorObject[] +} + +export class JsonInvalidAgainstSchema extends BaseError { + static displayName = 'JsonInvalidAgainstSchema' + + constructor(props: JsonInvalidAgainstSchemaProps) { + super( + props.errors + .map(({ instancePath, message, params, keyword }) => { + switch (keyword) { + case 'const': + return `${instancePath}: ${message}: ${params.allowedValue}` + case 'enum': + return `${instancePath}: ${message}: ${params.allowedValues.join(', ')}` + } + return `${instancePath}: ${message}` + }) + .join('.\n'), + props, + ) + } +} diff --git a/catalog/app/utils/json-schema/json-schema.ts b/catalog/app/utils/json-schema/json-schema.ts index f0af48c7498..399b7db4e6f 100644 --- a/catalog/app/utils/json-schema/json-schema.ts +++ b/catalog/app/utils/json-schema/json-schema.ts @@ -215,7 +215,7 @@ export function makeSchemaValidator( optSchema?: JsonSchema, optSchemas?: JsonSchema[], ajvOptions?: Options, -): (obj?: any) => (Error | ErrorObject)[] { +): (obj?: any) => [Error] | ErrorObject[] { let mainSchema = R.clone(optSchema || EMPTY_SCHEMA) if (!mainSchema.$id) { // Make further code more universal by using one format: `id` → `$id` @@ -246,7 +246,7 @@ export function makeSchemaValidator( // TODO: fail early, return Error instead of callback if (!$id) return () => [new Error('$id is not provided')] - return (obj: any): (Error | ErrorObject)[] => { + return (obj: any): [Error] | ErrorObject[] => { try { ajv.validate($id, R.clone(obj)) } catch (e) { @@ -258,7 +258,7 @@ export function makeSchemaValidator( } catch (e) { // TODO: fail early if Ajv options are incorrect, return Error instead of callback // TODO: add custom errors - return () => (e instanceof Error ? [e] : []) as Error[] + return () => (e instanceof Error ? [e] : []) } } diff --git a/catalog/app/utils/yaml.ts b/catalog/app/utils/yaml.ts index 5c8fb90a40e..567f0b8721d 100644 --- a/catalog/app/utils/yaml.ts +++ b/catalog/app/utils/yaml.ts @@ -16,3 +16,13 @@ export function parse(inputStr?: string) { console.error(error) } } + +export function validate(inputStr?: string) { + if (!inputStr) return undefined + try { + yaml.load(inputStr) + return undefined + } catch (error) { + return error + } +} diff --git a/shared/graphql/schema.graphql b/shared/graphql/schema.graphql index 41ebe809fe5..4215743b5df 100644 --- a/shared/graphql/schema.graphql +++ b/shared/graphql/schema.graphql @@ -122,6 +122,11 @@ type CollaboratorBucketConnection { permissionLevel: BucketPermissionLevel! } +type TabulatorTable { + name: String! + config: String! +} + type BucketConfig { name: String! title: String! @@ -141,6 +146,7 @@ type BucketConfig { associatedPolicies: [PolicyBucketPermission!]! @admin associatedRoles: [RoleBucketPermission!]! @admin collaborators: [CollaboratorBucketConnection!]! + tabulatorTables: [TabulatorTable!]! } # XXX: consider unifying ManagedRole and UnmanagedRole @@ -882,9 +888,13 @@ type UserAdminMutations { union SetSsoConfigResult = SsoConfig | InvalidInput | OperationError +union BucketSetTabulatorTableResult = BucketConfig | InvalidInput | OperationError + type AdminMutations { user: UserAdminMutations! setSsoConfig(config: String): SetSsoConfigResult + bucketSetTabulatorTable(bucketName: String!, tableName: String!, config: String): BucketSetTabulatorTableResult! + bucketRenameTabulatorTable(bucketName: String!, tableName: String!, newTableName: String!): BucketSetTabulatorTableResult! } type Mutation { diff --git a/shared/schemas/tabulatorTable.yml.json b/shared/schemas/tabulatorTable.yml.json new file mode 100644 index 00000000000..2c9e2603415 --- /dev/null +++ b/shared/schemas/tabulatorTable.yml.json @@ -0,0 +1,94 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://quiltdata.com/tabulator/config/1", + "type": "object", + "definitions": { + "CsvParser": { + "type": "object", + "properties": { + "format": { + "const": "csv" + }, + "header": { "type": "boolean" }, + "delimiter": { + "type": "string", + "minLength": 1, + "maxLength": 1 + } + }, + "required": ["format"] + }, + "ParquetParser": { + "type": "object", + "properties": { + "format": { + "const": "parquet" + } + } + }, + "package_source": { + "type": "object", + "properties": { + "type": { + "const": "quilt-packages" + }, + "package_name": { "type": "string" }, + "logical_key": { "type": "string" } + }, + "required": ["type", "package_name", "logical_key"] + }, + "schema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "type": { + "type": "string", + "enum": [ + "Utf8", + "Int8", + "Int16", + "Int32", + "Int64", + "UInt8", + "UInt16", + "UInt32", + "UInt64", + "Float16", + "Float32", + "Float64", + "Boolean", + "Date32", + "Date64", + "Timestamp(Second)", + "Timestamp(Millisecond)", + "Timestamp(Microsecond)", + "Timestamp(Nanosecond)" + ] + } + }, + "required": ["name", "type"] + } + }, + "properties": { + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/schema" + } + }, + "source": { + "$ref": "#/definitions/package_source" + }, + "parser": { + "oneOf": [ + { + "$ref": "#/definitions/CsvParser" + }, + { + "$ref": "#/definitions/ParquetParser" + } + ] + } + }, + "required": ["schema", "source", "parser"] +} From c5ededee0a54c978f47b8eba137e41584ba6d993 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Fri, 4 Oct 2024 11:16:26 -0700 Subject: [PATCH 2/2] rewrite SUMMARY (doc reorg) (#4149) Co-authored-by: Dr. Ernie Prabhakar <19791+drernie@users.noreply.github.com> Co-authored-by: QuiltSimon --- docs/README.md | 148 ++++++++++++++++++++++++++++++++++-------------- docs/SUMMARY.md | 122 ++++++++++++++++++++------------------- 2 files changed, 170 insertions(+), 100 deletions(-) diff --git a/docs/README.md b/docs/README.md index cbc81bef908..eb5afe0ba28 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,42 +1,106 @@ - -[![docs on_gitbook](https://img.shields.io/badge/docs-on_gitbook-blue.svg?style=flat-square)](https://docs.quiltdata.com/) -[![chat on_slack](https://img.shields.io/badge/chat-on_slack-blue.svg?style=flat-square)](https://slack.quiltdata.com/) - -# Quilt is a data mesh for connecting people with actionable data - -## Python Quick start, tutorials -If you have Python and an S3 bucket, you're ready to create versioned datasets with Quilt. -Visit the [Quilt docs](https://docs.quiltdata.com/installation) for installation instructions, -a quick start, and more. - -## Quilt in action -* [open.quiltdata.com](https://open.quiltdata.com/) is a petabyte-scale open -data portal that runs on Quilt -* [quiltdata.com](https://quiltdata.com) includes case studies, use cases, videos, -and instructions on how to run a private Quilt instance -* [Versioning data and models for rapid experimentation in machine learning](https://medium.com/pytorch/how-to-iterate-faster-in-machine-learning-by-versioning-data-and-models-featuring-detectron2-4fd2f9338df5) -shows how to use Quilt for real world projects - -## Who is Quilt for? -Quilt is for data-driven teams and offers features for coders (data scientists, -data engineers, developers) and business users alike. - -## What does Quilt do? -Quilt manages data like code so that teams in machine learning, biotech, -and analytics can experiment faster, build smarter models, and recover from errors. - -## How does Quilt work? -Quilt consists of a Python client, web catalog, lambda -functions—all of which are open source—plus -a suite of backend services and Docker containers -orchestrated by CloudFormation. - -The backend services are available under a paid license -on [quiltdata.com](https://quiltdata.com). - -## Use cases -* **Share** data at scale. Quilt wraps AWS S3 to add simple URLs, web preview for large files, and sharing via email address (no need to create an IAM role). -* **Understand** data better through inline documentation (Jupyter notebooks, markdown) and visualizations (Vega, Vega Lite) -* **Discover** related data by indexing objects in ElasticSearch -* **Model** data by providing a home for large data and models that don't fit in git, and by providing immutable versions for objects and data sets (a.k.a. "Quilt Packages") -* **Decide** by broadening data access within the organization and supporting the documentation of decision processes through audit-able versioning and inline documentation +# Quilt: A Data Lakehouse for Actionable Data + +Quilt connects teams to actionable data by simplifying data discovery, sharing, +and analysis. It’s designed to serve data-driven organizations with powerful +tools for managing data as code, enabling rapid experimentation, and ensuring +data integrity at scale. + +--- + +## Navigating the Documentation + +The Quilt documentation is structured to guide users through different layers of +the platform, from basic concepts to advanced integrations. Whether you're a +business user, developer, or platform administrator, the docs will help you +quickly find the information you need. + +### Quilt Platform Overview + +The **Quilt Platform** powers the core features of the Quilt data catalog, +providing tools for browsing, searching, and visualizing data stored in AWS S3. +The platform is ideal for teams needing to collaborate on data, with +capabilities like embeddable previews and metadata collection. + +**Core Sections:** + +- [Architecture](Architecture.md) - Learn how Quilt is architected. +- [Mental Model](MentalModel.md) - Understand the guiding principles behind + Quilt. +- [Metadata Management](Catalog/Metadata.md) - Manage metadata at scale. + +For users of the Quilt Platform (often referred to as the Catalog): + +- [Bucket Browsing](Catalog/FileBrowser.md) - Navigate through S3 buckets. +- [Document Previews](Catalog/Preview.md) - Visualize documents and datasets + directly in the web interface. +- [Search & Query](Catalog/SearchQuery.md) - Leverage Quilt’s powerful search + and querying capabilities. +- [Visualization & Dashboards](Catalog/VisualizationDashboards.md) - Create + visual dashboards for data insights. + +For administrators managing Quilt deployments: + +- [Admin Settings UI](Catalog/Admin.md) - Control platform settings and user + access. +- [Catalog Configuration](Catalog/Preferences.md) - Set platform preferences. +- [Cross-Account Access](CrossAccount.md) - Manage multi-account access to S3 + data. + +### Quilt Python SDK + +The **Quilt Python SDK** allows users to programmatically manage data packages, +version datasets, and automate data workflows. Whether you're uploading a +package, fetching data, or scripting custom workflows, the SDK provides the +flexibility needed for deeper integrations. + +- [Installation](Installation.md) - Get started with the Quilt SDK. +- [Quick Start](Quickstart.md) - Follow a step-by-step guide to building and + managing data packages. +- [Editing and Uploading Packages](walkthrough/editing-a-package.md) - Learn how + to version, edit, and share data. +- [API Reference](api-reference/api.md) - Detailed API documentation for + developers. + +### Quilt Ecosystem and Integrations + +The **Quilt Ecosystem** extends the platform with integrations and plugins to +fit your workflow. Whether you're managing scientific data or automating +packaging tasks, Quilt can be tailored to your needs with these tools: + +- [Benchling + Packager](https://open.quiltdata.com/b/quilt-example/packages/examples/benchling-packager) + - Package biological data from Benchling. +- [Nextflow Plugin](examples/nextflow.md) - Integrate with Nextflow pipelines + for bioinformatics. + +--- + +## Who Should Use Quilt? + +Quilt is for teams across industries like machine learning, biotech, and +analytics who need to manage large datasets, collaborate seamlessly, and track +the lifecycle of their data. Whether you're a data scientist, engineer, or +administrator, Quilt helps streamline your data management workflows. + +## What Can You Do with Quilt? + +- **Share**: Easily share versioned data using simple URLs and email invites. +- **Understand**: Enrich data with inline documentation and visualizations for + better insights. +- **Discover**: Use metadata and search tools to explore data relationships + across projects. +- **Model**: Version and manage large data sets that don't fit traditional git + repositories. +- **Decide**: Empower your team with auditable data for better decision-making. + +--- + +## How to Get Started + +To dive deeper into the capabilities of Quilt, start with our [Quick Start +Guide](Quickstart.md) or explore the [Installation +Instructions](Installation.md) for setting up your environment. + +If you have any questions or need help, join our [Slack +community](https://slack.quiltdata.com/) or visit our full [documentation +site](https://docs.quiltdata.com/). diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 0587f2d78e7..c9792898d67 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -1,66 +1,72 @@ - -# Summary +# Quilt Documentation -* [Introduction](README.md) -* [Installation](Installation.md) -* [Quick start](Quickstart.md) -* [Mental model](MentalModel.md) +* [About Quilt](README.md) * [Architecture](Architecture.md) +* [Mental Model](MentalModel.md) +* [Metadata Management](Catalog/Metadata.md) +* [Metadata Workflows](advanced-features/workflows.md) -### Walkthrough -* [Editing a Package](walkthrough/editing-a-package.md) -* [Uploading a Package](walkthrough/uploading-a-package.md) -* [Installing a Package](walkthrough/installing-a-package.md) -* [Getting Data from a Package](walkthrough/getting-data-from-a-package.md) -* [Working with the Catalog](walkthrough/working-with-the-catalog.md) -* [Working with a Bucket](walkthrough/working-with-a-bucket.md) -* [Working with Elasticsearch](walkthrough/working-with-elasticsearch.md) +## Quilt Platform (Catalog) User + +* [About the Catalog](walkthrough/working-with-the-catalog.md) +* [Bucket Browsing](Catalog/FileBrowser.md) +* [Document Previews](Catalog/Preview.md) +* [Embeddable iFrames](Catalog/Embed.md) +* [Search & Query](Catalog/SearchQuery.md) +* [Visualization & Dashboards](Catalog/VisualizationDashboards.md) +* **Advanced** + * [Athena](advanced-features/athena.md) + * [Elasticsearch](walkthrough/working-with-elasticsearch.md) -### API Reference -* [quilt3](api-reference/api.md) -* [quilt3.Package](api-reference/Package.md) -* [quilt3.Bucket](api-reference/Bucket.md) -* [quilt3.admin](api-reference/Admin.md) -* [CLI, environment](api-reference/cli.md) -* [Known limitations](api-reference/limitations.md) -* [Custom SSL certificates](api-reference/custom-ssl-certificates.md) +## Quilt Platform Administrator -### Catalog -* [Admin UI](Catalog/Admin.md) -* [Configuration](Catalog/Preferences.md) -* [Working with files](Catalog/FileBrowser.md) -* [Embed](Catalog/Embed.md) -* [Metadata for teams](Catalog/Metadata.md) -* [Preview](Catalog/Preview.md) -* [Search & query](Catalog/SearchQuery.md) -* [Visualization & dashboards](Catalog/VisualizationDashboards.md) -* [Local Development Mode](Catalog/LocalMode.md) +* [Admin Settings UI](Catalog/Admin.md) +* [Catalog Configuration](Catalog/Preferences.md) +* [Cross-Account Access](CrossAccount.md) +* [Enterprise Installs](technical-reference.md) +* [quilt3.admin Python API](api-reference/Admin.md) +* **Advanced** + * [Package Events](advanced-features/package-events.md) + * [Private Endpoints](advanced-features/private-endpoint-access.md) + * [Restrict Access by Bucket Prefix](advanced-features/s3-prefix-permissions.md) + * [S3 Events via EventBridge](EventBridge.md) + * [SSO Permissions Mapping](advanced-features/sso-permissions.md) +* **Best Practices** + * [GxP for Security & Compliance](advanced-features/good-practice.md) + * [Organizing S3 Buckets](advanced-features/s3-bucket-organization.md) -### Examples -* [Git-like operations for datasets and Jupyter notebooks](examples/GitLike.md) -* [Nextflow](examples/nextflow.md) +## Quilt Ecosystem Integrations -### Advanced -* [Filtering a Package](advanced-features/filtering-a-package.md) -* [.quiltignore](advanced-features/.quiltignore.md) -* [Materialization](advanced-features/materialization.md) -* [Working with Manifests](advanced-features/working-with-manifests.md) -* [S3 Select](advanced-features/s3-select.md) -* [Workflows](advanced-features/workflows.md) -* [Enterprise install](technical-reference.md) -* [S3 Events, EventBridge](EventBridge.md) -* [Cross-account access](CrossAccount.md) -* [Restrict access to bucket prefixes](advanced-features/s3-prefix-permissions.md) -* [Querying Metadata with Athena](advanced-features/athena.md) -* [S3 Bucket Organization](advanced-features/s3-bucket-organization.md) -* [Package events](advanced-features/package-events.md) -* [Event-driven packaging](advanced-features/event-driven-packaging.md) -* [GxP & Quilt](advanced-features/good-practice.md) -* [Private endpoints](advanced-features/private-endpoint-access.md) -* [SSO permissions mapping](advanced-features/sso-permissions.md) +* [Benchling Packager](https://open.quiltdata.com/b/quilt-example/packages/examples/benchling-packager) +* [Event-Driven Packaging](advanced-features/event-driven-packaging.md) +* [Nextflow Plugin](examples/nextflow.md) -### More -* [Frequently Asked Questions](FAQ.md) -* [Troubleshooting](Troubleshooting.md) -* [Contributing](CONTRIBUTING.md) -* [Changelog](CHANGELOG.md) +## Quilt Python SDK Developers + +* [Installation](Installation.md) +* [Quick Start](Quickstart.md) +* [Editing a Package](walkthrough/editing-a-package.md) +* [Uploading a Package](walkthrough/uploading-a-package.md) +* [Installing a Package](walkthrough/installing-a-package.md) +* [Getting Data from a Package](walkthrough/getting-data-from-a-package.md) +* [Example: Git-like Operations](examples/GitLike.md) +* **API Reference** + * [quilt3](api-reference/api.md) + * [quilt3.Package](api-reference/Package.md) + * [quilt3.Bucket](api-reference/Bucket.md) + * [Local Catalog](Catalog/LocalMode.md) + * [CLI, Environment](api-reference/cli.md) + * [Known Limitations](api-reference/limitations.md) + * [Custom SSL Certificates](api-reference/custom-ssl-certificates.md) +* **Advanced** + * [Browsing Buckets](walkthrough/working-with-a-bucket.md) + * [Filtering a Package](advanced-features/filtering-a-package.md) + * [.quiltignore](advanced-features/.quiltignore.md) + * [Manipulating Manifests](advanced-features/working-with-manifests.md) + * [Materialization](advanced-features/materialization.md) + * [S3 Select](advanced-features/s3-select.md) +* **More** + * [Changelog](CHANGELOG.md) + * [Contributing](CONTRIBUTING.md) + * [Frequently Asked Questions](FAQ.md) + * [Troubleshooting](Troubleshooting.md)