diff --git a/.github/workflows/deploy-catalog.yaml b/.github/workflows/deploy-catalog.yaml index 250f30ed2f8..639f70eb154 100644 --- a/.github/workflows/deploy-catalog.yaml +++ b/.github/workflows/deploy-catalog.yaml @@ -5,6 +5,7 @@ on: branches: - master - admin-bucket-page-long-query-config + - admin-bucket-page-long-query-config-redesign paths: - '.github/workflows/deploy-catalog.yaml' - 'catalog/**' diff --git a/catalog/app/components/FileEditor/TextEditor.tsx b/catalog/app/components/FileEditor/TextEditor.tsx index cd29ffebdef..622a438bc81 100644 --- a/catalog/app/components/FileEditor/TextEditor.tsx +++ b/catalog/app/components/FileEditor/TextEditor.tsx @@ -2,7 +2,6 @@ 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' @@ -12,16 +11,26 @@ import 'brace/theme/eclipse' const useEditorTextStyles = M.makeStyles((t) => ({ root: { - border: `1px solid ${t.palette.divider}`, + display: 'flex', + flexDirection: 'column', position: 'relative', width: '100%', }, editor: { - height: '100%', + 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), }, })) @@ -63,7 +72,7 @@ export default function TextEditor({ editor.setValue(initialValue, -1) if (leadingChange) { // Initially fill the value in the parent component. - // TODO: Re-design fetching data, so leading onChange wan't be necessary + // TODO: Re-design fetching data, so leading onChange won't be necessary // probably, by putting data fetch into FileEditor/State onChange(editor.getValue()) editor.focus() @@ -77,12 +86,12 @@ export default function TextEditor({ }, [leadingChange, onChange, ref, type.brace, initialValue]) return ( -
+
{error && ( - + {error.message} - + )} {disabled && }
diff --git a/catalog/app/containers/Admin/Buckets/Buckets.tsx b/catalog/app/containers/Admin/Buckets/Buckets.tsx index 4805a9a92b3..572e3d2155b 100644 --- a/catalog/app/containers/Admin/Buckets/Buckets.tsx +++ b/catalog/app/containers/Admin/Buckets/Buckets.tsx @@ -11,6 +11,7 @@ import useResizeObserver from 'use-resize-observer' import * as M from '@material-ui/core' import * as Lab from '@material-ui/lab' +import bucketIcon from 'components/BucketIcon/bucket.svg' import * as Buttons from 'components/Buttons' import * as Dialog from 'components/Dialog' import JsonDisplay from 'components/JsonDisplay' @@ -73,62 +74,98 @@ interface CardAvatarProps { } function CardAvatar({ className, src }: CardAvatarProps) { - if (src.startsWith('http')) return + if (src.startsWith('http') || src.startsWith('data')) { + return ( + + ) + } return {src} } const useCardStyles = M.makeStyles((t) => ({ - avatar: { - display: 'block', + icon: { + marginRight: t.spacing(2), }, header: { paddingBottom: t.spacing(1), }, content: { + cursor: 'default', paddingTop: 0, - '& > * + *': { + '& > *': { marginTop: t.spacing(1), }, }, + form: { + padding: t.spacing(0, 5), + flexGrow: 1, + }, + summaryInner: { + flexGrow: 1, + '&:empty': { + display: 'none', + }, + }, + summary: { + alignItems: 'flex-start', + cursor: 'default', + }, })) interface CardProps { + actions?: React.ReactNode children?: React.ReactNode className?: string disabled?: boolean + editing: boolean + form: React.ReactNode icon?: string | null - onEdit?: () => void - subTitle?: string - title: React.ReactNode + onEdit: (ex: boolean) => void + title: string } function Card({ + actions, children, className, disabled, + editing, + form, icon, onEdit, - subTitle, title, }: CardProps) { const classes = useCardStyles() return ( - - - edit - - ) - } - avatar={icon && } - className={classes.header} - subheader={subTitle} - title={title} - /> - {children && {children}} - + onEdit(ex)} + disabled={disabled} + > + {editing ? 'close' : 'edit'}} + > + {icon && } +
+ {title} + {!editing && !!children && ( +
event.stopPropagation()}> + {children} +
+ )} +
+
+ +
{form}
+
+ {actions && {actions}} +
) } @@ -573,17 +610,9 @@ function Hint({ children }: HintProps) { } const useInlineActionsStyles = M.makeStyles((t) => ({ - actions: { - display: 'flex', - justifyContent: 'flex-end', - padding: t.spacing(2, 0, 0), - '& > * + *': { - // Spacing between direct children - marginLeft: t.spacing(2), - }, - }, - error: { + helper: { flexGrow: 1, + marginLeft: t.spacing(6), }, })) @@ -600,10 +629,10 @@ function InlineActions({ form, onCancel }: InlineActionsProps) { onCancel() }, [form, onCancel]) return ( -
+ <> {state.submitFailed && ( {() => ( - +
- +
)} )} @@ -644,7 +673,7 @@ function InlineActions({ form, onCancel }: InlineActionsProps) { > Save -
+ ) } @@ -680,21 +709,12 @@ function InlineForm({ className, children, title }: InlineFormProps) { interface PrimaryFormProps { bucket?: BucketConfig className?: string - children?: React.ReactNode } -function PrimaryForm({ bucket, children, className }: PrimaryFormProps) { +function PrimaryForm({ bucket, className }: PrimaryFormProps) { return ( - - {bucket ? ( - - ) : ( +
+ {!bucket && ( - {children} - +
) } @@ -763,22 +782,18 @@ interface PrimaryCardProps { function PrimaryCard({ className, bucket, form }: PrimaryCardProps) { const [editing, setEditing] = React.useState(false) - if (editing) { - return ( - - setEditing(false)} /> - - ) - } return ( } + actions={ setEditing(false)} />} className={className} disabled={form.getState().submitting} - icon={bucket.iconUrl || undefined} - onEdit={() => setEditing(true)} - subTitle={`s3://${bucket.name}`} - title={bucket.title} + icon={bucket.iconUrl || bucketIcon} + onEdit={setEditing} + title={editing ? `s3://${bucket.name}` : bucket.title} + editing={editing} > + {`s3://${bucket.name}`} {bucket.description && ( {bucket.description} )} @@ -787,13 +802,12 @@ function PrimaryCard({ className, bucket, form }: PrimaryCardProps) { } interface MetadataFormProps { - children?: React.ReactNode - className: string + className?: string } -function MetadataForm({ children, className }: MetadataFormProps) { +function MetadataForm({ className }: MetadataFormProps) { return ( - +
- {children} - +
) } @@ -860,6 +872,16 @@ const useMetadataCardStyles = M.makeStyles((t) => ({ marginLeft: t.spacing(0.5), }, }, + content: { + flexDirection: 'column', + }, + form: { + flexGrow: 1, + }, + actions: { + padding: 0, + display: 'block', + }, })) interface MetadataCardProps { @@ -871,20 +893,16 @@ interface MetadataCardProps { function MetadataCard({ bucket, className, form }: MetadataCardProps) { const classes = useMetadataCardStyles() const [editing, setEditing] = React.useState(false) - if (editing) { - return ( - - setEditing(false)} /> - - ) - } return ( setEditing(true)} + onEdit={setEditing} title="Metadata" + form={} + actions={ setEditing(false)} />} > {bucket.description && ( {bucket.description} @@ -928,22 +946,20 @@ function MetadataCard({ bucket, className, form }: MetadataCardProps) { interface IndexingAndNotificationsFormProps { bucket?: BucketConfig - children?: React.ReactNode - className: string + className?: string reindex?: () => void settings: Model.GQLTypes.ContentIndexingSettings } function IndexingAndNotificationsForm({ bucket, - children, className, reindex, settings, }: IndexingAndNotificationsFormProps) { const classes = useIndexingAndNotificationsFormStyles() return ( - +
{!!reindex && ( @@ -1108,8 +1124,7 @@ function IndexingAndNotificationsForm({ /> )} - {children} - +
) } @@ -1142,25 +1157,22 @@ function IndexingAndNotificationsCard({ const data = GQL.useQueryS(CONTENT_INDEXING_SETTINGS_QUERY) const settings = data.config.contentIndexingSettings - if (editing) { - return ( - - setEditing(false)} /> - - ) - } - const { enableDeepIndexing, snsNotificationArn } = bucketToFormValues(bucket) return ( + } + actions={ setEditing(false)} />} + editing={editing} className={className} disabled={form.getState().submitting} - onEdit={() => setEditing(true)} + onEdit={setEditing} icon="find_in_page" title="Indexing and notifications" > @@ -1226,16 +1238,14 @@ function IndexingAndNotificationsCard({ } interface PreviewFormProps { - children?: React.ReactNode - className: string + className?: string } -function PreviewForm({ children, className }: PreviewFormProps) { +function PreviewForm({ className }: PreviewFormProps) { return ( - +
- {children} - +
) } @@ -1247,20 +1257,18 @@ interface PreviewCardProps { function PreviewCard({ bucket, className, form }: PreviewCardProps) { const [editing, setEditing] = React.useState(false) - if (editing) { - return ( - - setEditing(false)} /> - - ) - } return ( } + actions={ setEditing(false)} />} + editing={editing} className={className} disabled={form.getState().submitting} - onEdit={() => setEditing(true)} + onEdit={setEditing} icon="code" - title={`Permissive HTML rendering is ${bucket.browsable ? 'enabled' : 'disabled'}`} + title={`Permissive HTML rendering${ + editing ? '' : ` is ${bucket.browsable ? 'enabled' : 'disabled'}` + }`} /> ) } @@ -1287,22 +1295,19 @@ function LongQueryConfigCard({ tabulatorTables, }: LongQueryConfigCardProps) { const [editing, setEditing] = React.useState(false) - if (editing) { - return ( - + return ( + setEditing(false)} tabulatorTables={tabulatorTables} /> - - ) - } - return ( - setEditing(true)} + onEdit={setEditing} icon="query_builder" title={ tabulatorTables.length @@ -1320,6 +1325,17 @@ function LongQueryConfigCard({ const useStyles = M.makeStyles((t) => ({ card: { + marginTop: t.spacing(1), + '&:first-child': { + marginTop: 0, + }, + }, + formTitle: { + ...t.typography.h6, + marginBottom: t.spacing(2), + }, + form: { + padding: t.spacing(2), '& + &': { marginTop: t.spacing(2), }, @@ -1463,19 +1479,30 @@ function Add({ back, settings, submit }: AddProps) { Add a bucket
-
- - - - + + + + + + Metadata + + + + + Indexing and notifications + + + + + + + + + Longitudal query configs will be available after creating the bucket + + - - Longitudal query configs will be available after creating the bucket -
{submitFailed && ( @@ -1658,7 +1685,13 @@ interface BucketFieldSkeletonProps { } function BucketFieldSkeleton({ className, width }: BucketFieldSkeletonProps) { - return } /> + return ( + + + + + + ) } interface CardsPlaceholderProps { diff --git a/catalog/app/containers/Admin/Buckets/LongQueryConfig.tsx b/catalog/app/containers/Admin/Buckets/LongQueryConfig.tsx index bee2b783d41..856e155ec3e 100644 --- a/catalog/app/containers/Admin/Buckets/LongQueryConfig.tsx +++ b/catalog/app/containers/Admin/Buckets/LongQueryConfig.tsx @@ -5,12 +5,17 @@ import * as RF from 'react-final-form' import * as M from '@material-ui/core' import { fade } from '@material-ui/core/styles' +import federatorConfigSchema from 'schemas/federatorConfig.yml.json' + import { useConfirm } from 'components/Dialog' import { loadMode } from 'components/FileEditor/loader' +import * as Notifications from 'containers/Notifications' import type * as Model from 'model' import * as GQL from 'utils/GraphQL' import assertNever from 'utils/assertNever' +import { JsonInvalidAgainstSchema } from 'utils/error' import { mkFormError, mapInputErrors } from 'utils/formTools' +import { makeSchemaValidator } from 'utils/json-schema' import * as validators from 'utils/validators' import * as yaml from 'utils/yaml' @@ -32,7 +37,6 @@ function YamlEditorField({ input, meta, }: YamlEditorFieldProps) { - // TODO: convert yaml to json and validate with JSON Schema const error = meta.error || meta.submitError const errorMessage = meta.submitFailed && error ? errors[error] || error : undefined @@ -61,6 +65,17 @@ const validateYaml: FF.FieldValidator = (inputStr?: string) => { if (error) { return 'invalid' } + return undefined +} + +const validateConfig: FF.FieldValidator = (inputStr?: string) => { + const data = yaml.parse(inputStr) + const validator = makeSchemaValidator(federatorConfigSchema) + const errors = validator(data) + if (errors.length) { + return new JsonInvalidAgainstSchema({ errors }).message + } + return undefined } const useLongQueryConfigFormStyles = M.makeStyles((t) => ({ @@ -73,7 +88,7 @@ const useLongQueryConfigFormStyles = M.makeStyles((t) => ({ }, }, editor: { - height: t.spacing(20), + height: t.spacing(25), }, header: { alignItems: 'center', @@ -143,6 +158,8 @@ function LongQueryConfigForm({ const setTabulatorTable = GQL.useMutation(SET_TABULATOR_TABLE_MUTATION) const classes = useLongQueryConfigFormStyles() + const { push: notify } = Notifications.use() + const submitConfig = React.useCallback( async ( tableName: string, @@ -154,6 +171,7 @@ function LongQueryConfigForm({ } = await setTabulatorTable({ bucketName, tableName, config }) switch (r.__typename) { case 'BucketConfig': + notify(`Successfully updated ${tableName} config`) if (onClose) { onClose() } @@ -173,11 +191,15 @@ function LongQueryConfigForm({ return mkFormError('unexpected') } }, - [bucketName, onClose, setTabulatorTable], + [bucketName, notify, onClose, setTabulatorTable], ) const onSubmit = React.useCallback( - (values: FormValues) => submitConfig(values.name, values.config), + async (values: FormValues, form: FF.FormApi) => { + const result = await submitConfig(values.name, values.config) + form.reset(values) + return result + }, [submitConfig], ) const [deleting, setDeleting] = React.useState< @@ -223,6 +245,7 @@ function LongQueryConfigForm({ validate={validators.composeAnd( validators.required as FF.FieldValidator, validateYaml, + validateConfig, )} disabled={submitting || deleting} /> @@ -319,7 +342,7 @@ const useConfigsStyles = M.makeStyles((t) => ({ marginBottom: t.spacing(3), }, actions: { - marginTop: t.spacing(2), + margin: t.spacing(2, -5, 0), display: 'flex', justifyContent: 'flex-end', padding: t.spacing(2, 0, 0), @@ -334,7 +357,7 @@ const useConfigsStyles = M.makeStyles((t) => ({ interface ConfigsProps { bucket: string tabulatorTables: Model.GQLTypes.BucketConfig['tabulatorTables'] - onClose?: () => void // confirm if there are unsaved changes + onClose?: () => void // TODO: confirm if there are unsaved changes } export default function Configs({ bucket, tabulatorTables, onClose }: ConfigsProps) { @@ -342,7 +365,7 @@ export default function Configs({ bucket, tabulatorTables, onClose }: ConfigsPro loadMode('yaml') const [toAdd, setToAdd] = React.useState(tabulatorTables.length === 0) return ( -
+ <> {tabulatorTables.map((tabulatorTable) => (
-
+ ) } 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/utils/error.ts b/catalog/app/utils/error.ts index 1da7bb5ad4e..acf26e2e2cb 100644 --- a/catalog/app/utils/error.ts +++ b/catalog/app/utils/error.ts @@ -40,3 +40,22 @@ export class ErrorDisplay extends BaseError { super(headline, { headline, detail, object }) } } + +export interface JsonInvalidAgainstSchemaProps { + errors: { instancePath?: string; message?: string }[] +} + +export class JsonInvalidAgainstSchema extends BaseError { + static displayName = 'JsonInvalidAgainstSchema' + + constructor(props: JsonInvalidAgainstSchemaProps) { + super( + props.errors + .map(({ instancePath, message }) => + instancePath ? `${instancePath} ${message}` : message, + ) + .join(', '), + props, + ) + } +} diff --git a/shared/schemas/federatorConfig.yml.json b/shared/schemas/federatorConfig.yml.json new file mode 100644 index 00000000000..28895aa12a2 --- /dev/null +++ b/shared/schemas/federatorConfig.yml.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://quiltdata.com/federator/config/1", + "type": "object" +} +