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
-
-
- 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"
+}
+