diff --git a/catalog/CHANGELOG.md b/catalog/CHANGELOG.md index 20f5ae106ff..fb2e90d0025 100644 --- a/catalog/CHANGELOG.md +++ b/catalog/CHANGELOG.md @@ -17,6 +17,7 @@ where verb is one of ## Changes +- [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)) - [Added] Bootstrap the change log ([#4112](https://github.com/quiltdata/quilt/pull/4112)) diff --git a/catalog/app/constants/routes.ts b/catalog/app/constants/routes.ts index b0c413b723f..3117f87663e 100644 --- a/catalog/app/constants/routes.ts +++ b/catalog/app/constants/routes.ts @@ -262,11 +262,23 @@ export const admin: Route = { export const adminUsers = admin -export type AdminBucketsArgs = [bucket: string] +export type AdminBucketsArgs = [ + options?: { + add?: boolean + bucket?: string /* @deprecated legacy param */ + }, +] export const adminBuckets: Route<AdminBucketsArgs> = { path: '/admin/buckets', - url: (bucket) => `/admin/buckets${mkSearch({ bucket })}`, + url: ({ add, bucket } = {}) => `/admin/buckets${mkSearch({ add, bucket })}`, +} + +export type AdminBucketEditArgs = [bucket: string] + +export const adminBucketEdit: Route<AdminBucketEditArgs> = { + path: '/admin/buckets/:bucketName', + url: (bucketName) => `/admin/buckets/${bucketName}`, } export const adminSettings: Route = { diff --git a/catalog/app/containers/Admin/Admin.tsx b/catalog/app/containers/Admin/Admin.tsx index df74f6472bb..7611f3029a9 100644 --- a/catalog/app/containers/Admin/Admin.tsx +++ b/catalog/app/containers/Admin/Admin.tsx @@ -82,7 +82,7 @@ export default function Admin() { const sections = { users: { path: paths.adminUsers, exact: true }, - buckets: { path: paths.adminBuckets, exact: true }, + buckets: { path: paths.adminBuckets }, sync: { path: paths.adminSync, exact: true }, settings: { path: paths.adminSettings, exact: true }, status: { path: paths.adminStatus, exact: true }, @@ -105,9 +105,6 @@ export default function Admin() { <RR.Route path={paths.adminUsers} exact strict> <UsersAndRoles /> </RR.Route> - <RR.Route path={paths.adminBuckets} exact> - <Buckets /> - </RR.Route> {cfg.desktop && ( <RR.Route path={paths.adminSync} exact> <Sync /> @@ -119,6 +116,9 @@ export default function Admin() { <RR.Route path={paths.adminStatus} exact> <Status /> </RR.Route> + <RR.Route path={paths.adminBuckets}> + <Buckets /> + </RR.Route> <RR.Route> <ThrowNotFound /> </RR.Route> diff --git a/catalog/app/containers/Admin/Buckets/Buckets.tsx b/catalog/app/containers/Admin/Buckets/Buckets.tsx index eb3cceb03eb..a21ef812b14 100644 --- a/catalog/app/containers/Admin/Buckets/Buckets.tsx +++ b/catalog/app/containers/Admin/Buckets/Buckets.tsx @@ -1,4 +1,4 @@ -import * as dateFns from 'date-fns' +import cx from 'classnames' import * as FF from 'final-form' import * as FP from 'fp-ts' import * as IO from 'io-ts' @@ -6,39 +6,276 @@ import * as R from 'ramda' import * as React from 'react' import * as RF from 'react-final-form' import * as RRDom from 'react-router-dom' +import { useDebounce } from 'use-debounce' +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' +import * as Buttons from 'components/Buttons' import * as Dialog from 'components/Dialog' -import * as Pagination from 'components/Pagination' +import JsonDisplay from 'components/JsonDisplay' import Skeleton from 'components/Skeleton' import * as Notifications from 'containers/Notifications' import type * as Model from 'model' import * as APIConnector from 'utils/APIConnector' import Delay from 'utils/Delay' -import * as Dialogs from 'utils/Dialogs' 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 Table from '../Table' + +import ListPage, { ListSkeleton as ListPageSkeleton } from './List' import BUCKET_CONFIGS_QUERY from './gql/BucketConfigs.generated' import ADD_MUTATION from './gql/BucketsAdd.generated' import UPDATE_MUTATION from './gql/BucketsUpdate.generated' -import REMOVE_MUTATION from './gql/BucketsRemove.generated' import { BucketConfigSelectionFragment as BucketConfig } from './gql/BucketConfigSelection.generated' import CONTENT_INDEXING_SETTINGS_QUERY from './gql/ContentIndexingSettings.generated' +// TODO: organize skeletons + +const noop = () => {} + +const bucketToFormValues = (bucket: BucketConfig) => ({ + title: bucket.title, + iconUrl: bucket.iconUrl || '', + description: bucket.description || '', + relevanceScore: bucket.relevanceScore.toString(), + overviewUrl: bucket.overviewUrl || '', + tags: (bucket.tags || []).join(', '), + linkedData: bucket.linkedData ? JSON.stringify(bucket.linkedData) : '', + enableDeepIndexing: + !R.equals(bucket.fileExtensionsToIndex, []) && bucket.indexContentBytes !== 0, + fileExtensionsToIndex: (bucket.fileExtensionsToIndex || []).join(', '), + indexContentBytes: bucket.indexContentBytes, + scannerParallelShardsDepth: bucket.scannerParallelShardsDepth?.toString() || '', + snsNotificationArn: + bucket.snsNotificationArn === DO_NOT_SUBSCRIBE_STR + ? 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 <M.Avatar className={className} src={src} /> + return <M.Icon className={className}>{src}</M.Icon> +} + +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 +} + +function Card({ + children, + className, + disabled, + icon, + onEdit, + subTitle, + title, +}: CardProps) { + const classes = useCardStyles() + return ( + <M.Card className={className}> + <M.CardHeader + action={ + onEdit && ( + <M.IconButton onClick={onEdit} disabled={disabled}> + <M.Icon>edit</M.Icon> + </M.IconButton> + ) + } + avatar={icon && <CardAvatar className={classes.avatar} src={icon} />} + className={classes.header} + subheader={subTitle} + title={title} + /> + {children && <M.CardContent className={classes.content}>{children}</M.CardContent>} + </M.Card> + ) +} + +const useFormActionsStyles = M.makeStyles((t) => ({ + actions: { + animation: `$show 150ms ease-out`, + 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%)`, + }, + '@keyframes show': { + '0%': { + opacity: 0.3, + }, + '100%': { + opacity: '1', + }, + }, + '@keyframes sticking': { + '0%': { + transform: 'translate(-50%, 10%)', + }, + '100%': { + transform: 'translate(-50%, 0)', + }, + }, +})) + +interface FormActionsProps { + children: React.ReactNode + siblingRef: React.RefObject<HTMLElement> +} + +// 1. Listen scroll and sibling element resize +// 2. Get the bottom of `<FormActions />` 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() + + const [bottom, setBottom] = React.useState(0) + const ref = React.useRef<HTMLDivElement>(null) + const handleScroll = React.useCallback(() => { + const rect = ref.current?.getBoundingClientRect() + if (!rect || !rect.height) return + setBottom(rect.bottom) + }, []) + 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], + ) + + return ( + <div ref={ref}> + {sticky ? ( + <> + <M.Container className={classes.sticky} maxWidth="lg"> + <M.Paper className={classes.actions} elevation={8}> + {children} + </M.Paper> + </M.Container> + <div className={classes.placeholder} /> + </> + ) : ( + <div className={classes.actions}>{children}</div> + )} + </div> + ) +} + +const useSubPageHeaderStyles = M.makeStyles({ + root: { + display: 'flex', + }, + back: { + marginLeft: 'auto', + }, +}) + +interface SubPageHeaderProps { + back: () => void + children?: React.ReactNode + dirty?: boolean + disabled?: boolean + submit: () => void +} + +function SubPageHeader({ disabled, back, children, dirty, submit }: 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 ( + <div className={classes.root}> + {confirm.render(<></>)} + {children && ( + <M.Typography variant="h6" color="textPrimary"> + {children} + </M.Typography> + )} + <M.Button + className={classes.back} + disabled={disabled} + onClick={handleBack} + size="small" + startIcon={<M.Icon>arrow_back</M.Icon>} + > + Back to buckets + </M.Button> + </div> + ) +} + const SNS_ARN_RE = /^arn:aws(-|\w)*:sns:(-|\w)*:\d*:\S+$/ const DO_NOT_SUBSCRIBE_STR = 'DO_NOT_SUBSCRIBE' @@ -87,6 +324,7 @@ const usePFSCheckboxStyles = M.makeStyles({ marginTop: -9, }, }) + function PFSCheckbox({ input, meta }: Form.CheckboxProps & M.CheckboxProps) { const classes = usePFSCheckboxStyles() const confirm = React.useCallback((checked) => input?.onChange(checked), [input]) @@ -334,442 +572,812 @@ function Hint({ children }: HintProps) { ) } -const useBucketFieldsStyles = M.makeStyles((t) => ({ - group: { - '& > *:first-child': { - marginTop: 0, - }, - }, - panel: { - margin: '0 !important', - '&::before': { - content: 'none', +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), }, }, - panelSummary: { - padding: 0, - minHeight: 'auto !important', + error: { + flexGrow: 1, }, - panelSummaryContent: { - margin: `${t.spacing(1)}px 0 !important`, +})) + +interface InlineActionsProps { + form: FF.FormApi + onCancel: () => void +} + +function InlineActions({ form, onCancel }: InlineActionsProps) { + const classes = useInlineActionsStyles() + const state = form.getState() + const handleCancel = React.useCallback(() => { + form.reset() + onCancel() + }, [form, onCancel]) + return ( + <div className={classes.actions}> + {state.submitFailed && ( + <Form.FormError + className={classes.error} + error={state.error || state.submitError} + errors={{ + unexpected: 'Something went wrong', + notificationConfigurationError: 'Notification configuration error', + bucketNotFound: 'Bucket not found', + }} + margin="none" + /> + )} + {state.submitting && ( + <Delay> + {() => ( + <M.Box flexGrow={1} display="flex" pl={2}> + <M.CircularProgress size={24} /> + </M.Box> + )} + </Delay> + )} + <M.Button + onClick={() => form.reset()} + color="primary" + disabled={state.pristine || state.submitting} + > + Reset + </M.Button> + <M.Button onClick={handleCancel} color="primary" disabled={state.submitting}> + Cancel + </M.Button> + <M.Button + onClick={form.submit} + color="primary" + disabled={ + state.pristine || + state.submitting || + (state.submitFailed && state.hasValidationErrors) + } + variant="contained" + > + Save + </M.Button> + </div> + ) +} + +const useInlineFormStyles = M.makeStyles((t) => ({ + root: { + padding: t.spacing(2), }, - warning: { - background: t.palette.warning.main, + title: { marginBottom: t.spacing(1), - marginTop: t.spacing(2), - }, - warningIcon: { - color: t.palette.warning.dark, }, })) -interface BucketFieldsProps { - bucket?: BucketConfig - reindex?: () => void +interface InlineFormProps { + className?: string + title?: string + children: React.ReactNode } -function BucketFields({ bucket, reindex }: BucketFieldsProps) { - const classes = useBucketFieldsStyles() +function InlineForm({ className, children, title }: InlineFormProps) { + const classes = useInlineFormStyles() + return ( + <M.Paper className={cx(classes.root, className)}> + {title && ( + <M.Typography className={classes.title} variant="h6"> + {title} + </M.Typography> + )} + {children} + </M.Paper> + ) +} - const data = GQL.useQueryS(CONTENT_INDEXING_SETTINGS_QUERY) - const settings = data.config.contentIndexingSettings +interface PrimaryFormProps { + bucket?: BucketConfig + className?: string + children?: React.ReactNode +} +function PrimaryForm({ bucket, children, className }: PrimaryFormProps) { return ( - <M.Box> - <M.Box className={classes.group} mt={-1} pb={2}> - {bucket ? ( - <M.TextField - label="Name" - value={bucket.name} - fullWidth - margin="normal" - disabled - /> - ) : ( - <RF.Field - component={Form.Field} - name="name" - label="Name" - placeholder="Enter an S3 bucket name" - parse={R.pipe( - R.toLower, - R.replace(/[^a-z0-9-.]/g, ''), - R.take(63) as (s: string) => string, - )} - validate={validators.required as FF.FieldValidator<any>} - errors={{ - required: 'Enter a bucket name', - conflict: 'Bucket already added', - noSuchBucket: 'No such bucket', - }} - fullWidth - margin="normal" - /> - )} + <InlineForm className={className}> + {bucket ? ( + <M.TextField + label="Name" + value={bucket.name} + fullWidth + margin="normal" + disabled + /> + ) : ( <RF.Field component={Form.Field} - name="title" - label="Title" - placeholder='e.g. "Production analytics data"' - parse={R.pipe(R.replace(/^\s+/g, ''), R.take(256) as (s: string) => string)} + name="name" + label="Name" + placeholder="Enter an S3 bucket name" + parse={R.pipe( + R.toLower, + R.replace(/[^a-z0-9-.]/g, ''), + R.take(63) as (s: string) => string, + )} validate={validators.required as FF.FieldValidator<any>} errors={{ - required: 'Enter a bucket title', + required: 'Enter a bucket name', + conflict: 'Bucket already added', + noSuchBucket: 'No such bucket', }} fullWidth margin="normal" /> - <RF.Field - component={Form.Field} - name="iconUrl" - label="Icon URL (optional)" - placeholder="e.g. https://some-cdn.com/icon.png" - helperText="Recommended size: 80x80px" - parse={R.pipe(R.trim, R.take(1024) as (s: string) => string)} - fullWidth - margin="normal" + )} + <RF.Field + component={Form.Field} + name="title" + label="Title" + placeholder='e.g. "Production analytics data"' + parse={R.pipe(R.replace(/^\s+/g, ''), R.take(256) as (s: string) => string)} + validate={validators.required as FF.FieldValidator<any>} + errors={{ + required: 'Enter a bucket title', + }} + fullWidth + margin="normal" + /> + <RF.Field + component={Form.Field} + name="iconUrl" + label="Icon URL (optional)" + placeholder="e.g. https://some-cdn.com/icon.png" + helperText="Recommended size: 80x80px" + parse={R.pipe(R.trim, R.take(1024) as (s: string) => string)} + fullWidth + margin="normal" + /> + <RF.Field + component={Form.Field} + name="description" + label="Description (optional)" + placeholder="Enter description if required" + parse={R.pipe(R.replace(/^\s+/g, ''), R.take(1024) as (s: string) => string)} + multiline + rows={1} + rowsMax={3} + fullWidth + margin="normal" + /> + {children} + </InlineForm> + ) +} + +interface PrimaryCardProps { + bucket: BucketConfig + className: string + form: FF.FormApi +} + +function PrimaryCard({ className, bucket, form }: PrimaryCardProps) { + const [editing, setEditing] = React.useState(false) + if (editing) { + return ( + <PrimaryForm className={className} bucket={bucket}> + <InlineActions form={form} onCancel={() => setEditing(false)} /> + </PrimaryForm> + ) + } + return ( + <Card + className={className} + disabled={form.getState().submitting} + icon={bucket.iconUrl || undefined} + onEdit={() => setEditing(true)} + subTitle={`s3://${bucket.name}`} + title={bucket.title} + > + {bucket.description && ( + <M.Typography variant="body2">{bucket.description}</M.Typography> + )} + </Card> + ) +} + +interface MetadataFormProps { + children?: React.ReactNode + className: string +} + +function MetadataForm({ children, className }: MetadataFormProps) { + return ( + <InlineForm className={className} title="Metadata"> + <RF.Field + component={Form.Field} + name="relevanceScore" + label="Relevance score" + placeholder="Higher numbers appear first, -1 to hide" + parse={R.pipe( + R.replace(/[^0-9-]/g, ''), + R.replace(/(.+)-+$/g, '$1'), + R.take(16) as (s: string) => string, + )} + validate={validators.integer as FF.FieldValidator<any>} + errors={{ + integer: 'Enter a valid integer', + }} + fullWidth + margin="normal" + /> + <RF.Field + component={Form.Field} + name="tags" + label="Tags (comma-separated)" + placeholder='e.g. "geospatial", for bucket discovery' + fullWidth + margin="normal" + multiline + rows={1} + rowsMax={3} + /> + <RF.Field + component={Form.Field} + name="overviewUrl" + label="Overview URL" + parse={R.trim} + fullWidth + margin="normal" + /> + <RF.Field + component={Form.Field} + name="linkedData" + label="Structured data (JSON-LD)" + validate={validators.jsonObject as FF.FieldValidator<any>} + errors={{ + jsonObject: 'Must be a valid JSON object', + }} + fullWidth + multiline + rows={1} + rowsMax={10} + margin="normal" + /> + {children} + </InlineForm> + ) +} + +const useMetadataCardStyles = M.makeStyles((t) => ({ + tagsList: { + marginBottoM: t.spacing(-1), + }, + tag: { + marginBottom: t.spacing(1), + verticalAlign: 'baseline', + '& + &': { + marginLeft: t.spacing(0.5), + }, + }, +})) + +interface MetadataCardProps { + bucket: BucketConfig + className: string + form: FF.FormApi +} + +function MetadataCard({ bucket, className, form }: MetadataCardProps) { + const classes = useMetadataCardStyles() + const [editing, setEditing] = React.useState(false) + if (editing) { + return ( + <MetadataForm className={className}> + <InlineActions form={form} onCancel={() => setEditing(false)} /> + </MetadataForm> + ) + } + return ( + <Card + className={className} + disabled={form.getState().submitting} + icon="toc" + onEdit={() => setEditing(true)} + title="Metadata" + > + {bucket.description && ( + <M.Typography variant="body2">{bucket.description}</M.Typography> + )} + <M.Typography variant="body2"> + Relevance score: {bucket.relevanceScore.toString()} + </M.Typography> + {bucket.tags && ( + <M.Typography variant="body2" className={classes.tagsList}> + Tags:{' '} + {bucket.tags.map((tag) => ( + <M.Chip + className={classes.tag} + label={tag} + key={tag} + component="span" + size="small" + /> + ))} + </M.Typography> + )} + {bucket.overviewUrl && ( + <M.Typography variant="body2"> + Overview URL:{' '} + <StyledLink href={bucket.overviewUrl} target="_blank"> + {bucket.overviewUrl} + </StyledLink> + </M.Typography> + )} + {bucket.linkedData && ( + // @ts-expect-error + <JsonDisplay + name="Structured data (JSON-LD)" + topLevel + value={bucket.linkedData} /> + )} + </Card> + ) +} + +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 ( + <InlineForm className={className} title="Indexing and notifications"> + {!!reindex && ( + <M.Box pb={2.5}> + <M.Button variant="outlined" fullWidth onClick={reindex}> + Re-index and repair + </M.Button> + </M.Box> + )} + + <RF.Field + component={Form.Checkbox} + type="checkbox" + name="enableDeepIndexing" + label={ + <> + Enable deep indexing + <Hint> + Deep indexing adds the <em>contents</em> of an object to your search index, + while shallow indexing only covers object metadata. Deep indexing may + require more disk in ElasticSearch. Enable deep indexing when you want your + users to find files by their contents. + </Hint> + </> + } + /> + + <RF.FormSpy subscription={{ modified: true, values: true }}> + {({ modified, values }) => { + // don't need this while adding a bucket + if (!bucket) return null + if ( + !modified?.enableDeepIndexing && + !modified?.fileExtensionsToIndex && + !modified?.indexContentBytes + ) + return null + try { + if ( + R.equals( + bucket.fileExtensionsToIndex, + editFormSpec.fileExtensionsToIndex(values), + ) && + R.equals(bucket.indexContentBytes, editFormSpec.indexContentBytes(values)) + ) + return null + } catch { + return null + } + + return ( + <Lab.Alert + className={classes.warning} + icon={ + <M.Icon fontSize="inherit" className={classes.warningIcon}> + error + </M.Icon> + } + severity="warning" + > + Changing these settings affects files that are indexed after the change. If + you wish to deep index existing files, click{' '} + <strong>"Re-index and repair"</strong>. + </Lab.Alert> + ) + }} + </RF.FormSpy> + + <RF.FormSpy subscription={{ values: true }}> + {({ values }) => { + if (!values.enableDeepIndexing) return null + return ( + <> + <RF.Field + component={Form.Field} + name="fileExtensionsToIndex" + label={ + <> + File extensions to deep index (comma-separated) + <Hint> + Default extensions: + <ul> + {settings.extensions.map((ext) => ( + <li key={ext}>{ext}</li> + ))} + </ul> + </Hint> + </> + } + placeholder='e.g. ".txt, .md", leave blank to use default settings' + validate={validateExtensions} + errors={{ + validExtensions: ( + <> + Enter a comma-separated list of{' '} + <abbr title="Must start with the dot and contain only alphanumeric characters thereafter"> + valid + </abbr>{' '} + file extensions + </> + ), + }} + fullWidth + margin="normal" + multiline + rows={1} + rowsMax={3} + /> + <RF.Field + component={Form.Field} + name="indexContentBytes" + label={ + <> + Content bytes to deep index + <Hint>Defaults to {settings.bytesDefault}</Hint> + </> + } + placeholder='e.g. "1024", leave blank to use default settings' + parse={R.replace(/[^0-9]/g, '')} + validate={integerInRange(settings.bytesMin, settings.bytesMax)} + errors={{ + integerInRange: ( + <> + Enter an integer from {settings.bytesMin} to {settings.bytesMax} + </> + ), + }} + fullWidth + margin="normal" + /> + </> + ) + }} + </RF.FormSpy> + <RF.Field + component={Form.Field} + name="scannerParallelShardsDepth" + label="Scanner parallel shards depth" + placeholder="Leave blank to use default settings" + validate={validators.integer as FF.FieldValidator<any>} + errors={{ + integer: 'Enter a valid integer', + }} + parse={R.pipe(R.replace(/[^0-9]/g, ''), R.take(16) as (s: string) => string)} + fullWidth + margin="normal" + /> + <RF.Field component={SnsField} name="snsNotificationArn" validate={validateSns} /> + <M.Box mt={2}> <RF.Field - component={Form.Field} - name="description" - label="Description (optional)" - placeholder="Enter description if required" - parse={R.pipe(R.replace(/^\s+/g, ''), R.take(1024) as (s: string) => string)} - multiline - rows={1} - rowsMax={3} - fullWidth - margin="normal" + component={Form.Checkbox} + type="checkbox" + name="skipMetaDataIndexing" + label="Skip metadata indexing" /> </M.Box> - <M.Accordion elevation={0} className={classes.panel}> - <M.AccordionSummary - expandIcon={<M.Icon>expand_more</M.Icon>} - classes={{ - root: classes.panelSummary, - content: classes.panelSummaryContent, - }} - > - <M.Typography variant="h6">Metadata</M.Typography> - </M.AccordionSummary> - <M.Box className={classes.group} pt={1} pb={2}> - <RF.Field - component={Form.Field} - name="relevanceScore" - label="Relevance score" - placeholder="Higher numbers appear first, -1 to hide" - parse={R.pipe( - R.replace(/[^0-9-]/g, ''), - R.replace(/(.+)-+$/g, '$1'), - R.take(16) as (s: string) => string, - )} - validate={validators.integer as FF.FieldValidator<any>} - errors={{ - integer: 'Enter a valid integer', - }} - fullWidth - margin="normal" - /> + {!bucket && ( + <M.Box mt={1}> <RF.Field - component={Form.Field} - name="tags" - label="Tags (comma-separated)" - placeholder='e.g. "geospatial", for bucket discovery' - fullWidth - margin="normal" - multiline - rows={1} - rowsMax={3} - /> - <RF.Field - component={Form.Field} - name="overviewUrl" - label="Overview URL" - parse={R.trim} - fullWidth - margin="normal" - /> - <RF.Field - component={Form.Field} - name="linkedData" - label="Structured data (JSON-LD)" - validate={validators.jsonObject as FF.FieldValidator<any>} - errors={{ - jsonObject: 'Must be a valid JSON object', - }} - fullWidth - multiline - rows={1} - rowsMax={10} - margin="normal" + component={Form.Checkbox} + type="checkbox" + name="delayScan" + label="Delay scan" /> </M.Box> - </M.Accordion> - <M.Accordion elevation={0} className={classes.panel}> - <M.AccordionSummary - expandIcon={<M.Icon>expand_more</M.Icon>} - classes={{ - root: classes.panelSummary, - content: classes.panelSummaryContent, - }} + )} + {children} + </InlineForm> + ) +} + +const useIndexingAndNotificationsFormStyles = M.makeStyles((t) => ({ + warning: { + background: t.palette.warning.main, + marginBottom: t.spacing(1), + marginTop: t.spacing(2), + }, + warningIcon: { + color: t.palette.warning.dark, + }, +})) + +interface IndexingAndNotificationsCardProps { + bucket: BucketConfig + className: string + form: FF.FormApi + reindex?: () => void +} + +function IndexingAndNotificationsCard({ + bucket, + className, + form, + reindex, +}: IndexingAndNotificationsCardProps) { + const [editing, setEditing] = React.useState(false) + + const data = GQL.useQueryS(CONTENT_INDEXING_SETTINGS_QUERY) + const settings = data.config.contentIndexingSettings + + if (editing) { + return ( + <IndexingAndNotificationsForm + bucket={bucket} + className={className} + reindex={reindex} + settings={settings} + > + <InlineActions form={form} onCancel={() => setEditing(false)} /> + </IndexingAndNotificationsForm> + ) + } + + const { enableDeepIndexing, snsNotificationArn } = bucketToFormValues(bucket) + return ( + <Card + className={className} + disabled={form.getState().submitting} + onEdit={() => setEditing(true)} + icon="find_in_page" + title="Indexing and notifications" + > + {!!reindex && ( + <M.Button + variant="outlined" + onClick={reindex} + size="small" + disabled={form.getState().submitting} > - <M.Typography variant="h6">Indexing and notifications</M.Typography> - </M.AccordionSummary> - <M.Box className={classes.group} pt={1}> - {!!reindex && ( - <M.Box pb={2.5}> - <M.Button variant="outlined" fullWidth onClick={reindex}> - Re-index and repair - </M.Button> - </M.Box> + Re-index and repair + </M.Button> + )} + {enableDeepIndexing ? ( + <> + {bucket.fileExtensionsToIndex ? ( + <M.Typography variant="body2"> + File extensions to deep index: + {bucket.fileExtensionsToIndex.join(', ')} + </M.Typography> + ) : ( + <M.Typography variant="body2"> + Default file extensions to deep index: + {settings.extensions.join(', ')} + </M.Typography> + )} + {bucket.indexContentBytes ? ( + <M.Typography variant="body2"> + Content bytes to deep index is {formatQuantity(bucket.indexContentBytes)}{' '} + bytes + </M.Typography> + ) : ( + <M.Typography variant="body2"> + Default content bytes to deep index is{' '} + {formatQuantity(settings.bytesDefault)} bytes + </M.Typography> )} + </> + ) : ( + <M.Typography variant="body2">Deep indexing is disabled</M.Typography> + )} + {bucket.scannerParallelShardsDepth && ( + <M.Typography variant="body2"> + Scanner parallel shards depth: {bucket.scannerParallelShardsDepth} + </M.Typography> + )} + {bucket.skipMetaDataIndexing && ( + <M.Typography variant="body2">Metadata indexing is disabled</M.Typography> + )} + {typeof snsNotificationArn === 'string' && ( + <M.Typography variant="body2"> + SNS Topic ARN:{' '} + <StyledLink + href={`https://console.aws.amazon.com/sns/v3/home?#/topic/${bucket.snsNotificationArn}`} + target="_blank" + > + {bucket.snsNotificationArn} + </StyledLink> + </M.Typography> + )} + </Card> + ) +} - <RF.Field - component={Form.Checkbox} - type="checkbox" - name="enableDeepIndexing" - label={ - <> - Enable deep indexing - <Hint> - Deep indexing adds the <em>contents</em> of an object to your search - index, while shallow indexing only covers object metadata. Deep indexing - may require more disk in ElasticSearch. Enable deep indexing when you - want your users to find files by their contents. - </Hint> - </> - } - /> +interface PreviewFormProps { + children?: React.ReactNode + className: string +} - <RF.FormSpy subscription={{ modified: true, values: true }}> - {({ modified, values }) => { - // don't need this while adding a bucket - if (!bucket) return null - if ( - !modified?.enableDeepIndexing && - !modified?.fileExtensionsToIndex && - !modified?.indexContentBytes - ) - return null - try { - if ( - R.equals( - bucket.fileExtensionsToIndex, - editFormSpec.fileExtensionsToIndex(values), - ) && - R.equals( - bucket.indexContentBytes, - editFormSpec.indexContentBytes(values), - ) - ) - return null - } catch { - return null - } +function PreviewForm({ children, className }: PreviewFormProps) { + return ( + <InlineForm className={className}> + <RF.Field component={PFSCheckbox} name="browsable" type="checkbox" /> + {children} + </InlineForm> + ) +} - return ( - <Lab.Alert - className={classes.warning} - icon={ - <M.Icon fontSize="inherit" className={classes.warningIcon}> - error - </M.Icon> - } - severity="warning" - > - Changing these settings affects files that are indexed after the change. - If you wish to deep index existing files, click{' '} - <strong>"Re-index and repair"</strong>. - </Lab.Alert> - ) - }} - </RF.FormSpy> - - <RF.FormSpy subscription={{ values: true }}> - {({ values }) => { - if (!values.enableDeepIndexing) return null - return ( - <> - <RF.Field - component={Form.Field} - name="fileExtensionsToIndex" - label={ - <> - File extensions to deep index (comma-separated) - <Hint> - Default extensions: - <ul> - {settings.extensions.map((ext) => ( - <li key={ext}>{ext}</li> - ))} - </ul> - </Hint> - </> - } - placeholder='e.g. ".txt, .md", leave blank to use default settings' - validate={validateExtensions} - errors={{ - validExtensions: ( - <> - Enter a comma-separated list of{' '} - <abbr title="Must start with the dot and contain only alphanumeric characters thereafter"> - valid - </abbr>{' '} - file extensions - </> - ), - }} - fullWidth - margin="normal" - multiline - rows={1} - rowsMax={3} - /> - <RF.Field - component={Form.Field} - name="indexContentBytes" - label={ - <> - Content bytes to deep index - <Hint>Defaults to {settings.bytesDefault}</Hint> - </> - } - placeholder='e.g. "1024", leave blank to use default settings' - parse={R.replace(/[^0-9]/g, '')} - validate={integerInRange(settings.bytesMin, settings.bytesMax)} - errors={{ - integerInRange: ( - <> - Enter an integer from {settings.bytesMin} to {settings.bytesMax} - </> - ), - }} - fullWidth - margin="normal" - /> - </> - ) - }} - </RF.FormSpy> - <RF.Field - component={Form.Field} - name="scannerParallelShardsDepth" - label="Scanner parallel shards depth" - placeholder="Leave blank to use default settings" - validate={validators.integer as FF.FieldValidator<any>} - errors={{ - integer: 'Enter a valid integer', - }} - parse={R.pipe(R.replace(/[^0-9]/g, ''), R.take(16) as (s: string) => string)} - fullWidth - margin="normal" - /> - <RF.Field - component={SnsField} - name="snsNotificationArn" - validate={validateSns} - /> - <M.Box mt={2}> - <RF.Field - component={Form.Checkbox} - type="checkbox" - name="skipMetaDataIndexing" - label="Skip metadata indexing" - /> - </M.Box> - {!bucket && ( - <M.Box mt={1}> - <RF.Field - component={Form.Checkbox} - type="checkbox" - name="delayScan" - label="Delay scan" - /> - </M.Box> - )} - </M.Box> - </M.Accordion> - <M.Accordion elevation={0} className={classes.panel}> - <M.AccordionSummary - expandIcon={<M.Icon>expand_more</M.Icon>} - classes={{ - root: classes.panelSummary, - content: classes.panelSummaryContent, - }} - > - <M.Typography variant="h6">File preview options</M.Typography> - </M.AccordionSummary> - <M.Box className={classes.group} pt={1}> - <RF.Field component={PFSCheckbox} name="browsable" type="checkbox" /> - </M.Box> - </M.Accordion> - </M.Box> +interface PreviewCardProps { + className: string + bucket: BucketConfig + form: FF.FormApi +} + +function PreviewCard({ bucket, className, form }: PreviewCardProps) { + const [editing, setEditing] = React.useState(false) + if (editing) { + return ( + <PreviewForm className={className}> + <InlineActions form={form} onCancel={() => setEditing(false)} /> + </PreviewForm> + ) + } + return ( + <Card + className={className} + disabled={form.getState().submitting} + onEdit={() => setEditing(true)} + icon="code" + title={`Permissive HTML rendering is ${bucket.browsable ? 'enabled' : 'disabled'}`} + /> ) } -function BucketFieldsPlaceholder() { +const useStyles = M.makeStyles((t) => ({ + card: { + '& + &': { + marginTop: t.spacing(2), + }, + }, + error: { + flexGrow: 1, + }, + fields: { + marginTop: t.spacing(2), + }, +})) + +interface AddPageSkeletonProps { + back: () => void +} + +function AddPageSkeleton({ back }: AddPageSkeletonProps) { + const classes = useStyles() + const formRef = React.useRef<HTMLDivElement>(null) return ( <> - {R.times( - (i) => ( - <Skeleton key={i} height={48} mt={i ? 3 : 0} /> - ), - 5, - )} + <SubPageHeader back={back} submit={noop}> + Add a bucket + </SubPageHeader> + <div className={classes.fields} ref={formRef}> + <InlineForm className={classes.card}> + <Skeleton height={54} /> + <Skeleton height={54} mt={4} /> + <Skeleton height={54} mt={4} /> + </InlineForm> + <InlineForm className={classes.card}> + <Skeleton height={54} /> + <Skeleton height={54} mt={4} /> + <Skeleton height={54} mt={4} /> + </InlineForm> + <InlineForm className={classes.card}> + <Skeleton height={54} /> + <Skeleton height={54} mt={4} /> + <Skeleton height={54} mt={4} /> + </InlineForm> + <InlineForm className={classes.card}> + <Skeleton height={54} /> + </InlineForm> + <FormActions siblingRef={formRef}> + <Buttons.Skeleton /> + <Buttons.Skeleton /> + </FormActions> + </div> </> ) } +function parseResponseError( + r: + | Exclude<Model.GQLTypes.BucketAddResult, Model.GQLTypes.BucketAddSuccess> + | Exclude<Model.GQLTypes.BucketUpdateResult, Model.GQLTypes.BucketUpdateSuccess>, +): FF.SubmissionErrors | undefined { + switch (r.__typename) { + case 'BucketAlreadyAdded': + return { name: 'conflict' } + case 'BucketDoesNotExist': + return { name: 'noSuchBucket' } + case 'SnsInvalid': + // shouldnt happen since we're validating it + return { snsNotificationArn: 'invalidArn' } + case 'NotificationTopicNotFound': + return { snsNotificationArn: 'topicNotFound' } + case 'NotificationConfigurationError': + return { + snsNotificationArn: 'configurationError', + [FF.FORM_ERROR]: 'notificationConfigurationError', + } + case 'InsufficientPermissions': + return { [FF.FORM_ERROR]: 'insufficientPermissions' } + case 'SubscriptionInvalid': + return { [FF.FORM_ERROR]: 'subscriptionInvalid' } + case 'BucketIndexContentBytesInvalid': + // shouldnt happen since we valide input + return { indexContentBytes: 'integerInRange' } + case 'BucketFileExtensionsToIndexInvalid': + // shouldnt happen since we valide input + return { fileExtensionsToIndex: 'validExtensions' } + case 'BucketNotFound': + return { [FF.FORM_ERROR]: 'bucketNotFound' } + default: + return assertNever(r) + } +} + interface AddProps { - close: (reason?: string) => void + back: (reason?: string) => void + settings: Model.GQLTypes.ContentIndexingSettings + submit: ( + input: Model.GQLTypes.BucketAddInput, + ) => Promise< + | Exclude<Model.GQLTypes.BucketAddResult, Model.GQLTypes.BucketAddSuccess> + | Error + | undefined + > } -function Add({ close }: AddProps) { - const { push } = Notifications.use() - const t = useTracker() - const add = GQL.useMutation(ADD_MUTATION) +function Add({ back, settings, submit }: AddProps) { + const classes = useStyles() const onSubmit = React.useCallback( async (values) => { try { const input = R.applySpec(addFormSpec)(values) - const { bucketAdd: r } = await add({ input }) - switch (r.__typename) { - case 'BucketAddSuccess': - push(`Bucket "${r.bucketConfig.name}" added`) - t.track('WEB', { - type: 'admin', - action: 'bucket add', - bucket: r.bucketConfig.name, - }) - close() - return undefined - case 'BucketAlreadyAdded': - return { name: 'conflict' } - case 'BucketDoesNotExist': - return { name: 'noSuchBucket' } - case 'SnsInvalid': - // shouldnt happen since we're validating it - return { snsNotificationArn: 'invalidArn' } - case 'NotificationTopicNotFound': - return { snsNotificationArn: 'topicNotFound' } - case 'NotificationConfigurationError': - return { - snsNotificationArn: 'configurationError', - [FF.FORM_ERROR]: 'notificationConfigurationError', - } - case 'InsufficientPermissions': - return { [FF.FORM_ERROR]: 'insufficientPermissions' } - case 'SubscriptionInvalid': - return { [FF.FORM_ERROR]: 'subscriptionInvalid' } - case 'BucketIndexContentBytesInvalid': - // shouldnt happen since we valide input - return { indexContentBytes: 'integerInRange' } - case 'BucketFileExtensionsToIndexInvalid': - // shouldnt happen since we valide input - return { fileExtensionsToIndex: 'validExtensions' } - default: - return assertNever(r) - } + const error = await submit(input) + if (!error) return + if (error instanceof Error) throw error + return parseResponseError(error) } catch (e) { // eslint-disable-next-line no-console console.error('Error adding bucket') @@ -778,12 +1386,13 @@ function Add({ close }: AddProps) { return { [FF.FORM_ERROR]: 'unexpected' } } }, - [add, push, close, t], + [submit], ) - + const formRef = React.useRef<HTMLFormElement>(null) return ( <RF.Form onSubmit={onSubmit} initialValues={{ enableDeepIndexing: true }}> {({ + dirty, handleSubmit, submitting, submitFailed, @@ -792,27 +1401,35 @@ function Add({ close }: AddProps) { hasValidationErrors, }) => ( <> - <M.DialogTitle>Add a bucket</M.DialogTitle> - <M.DialogContent> - <React.Suspense fallback={<BucketFieldsPlaceholder />}> - <form onSubmit={handleSubmit}> - <BucketFields /> - {submitFailed && ( - <Form.FormError - error={error || submitError} - errors={{ - unexpected: 'Something went wrong', - notificationConfigurationError: 'Notification configuration error', - insufficientPermissions: 'Insufficient permissions', - subscriptionInvalid: 'Subscription invalid', - }} - /> - )} - <input type="submit" style={{ display: 'none' }} /> - </form> - </React.Suspense> - </M.DialogContent> - <M.DialogActions> + <SubPageHeader + back={back} + dirty={dirty} + disabled={submitting} + submit={handleSubmit} + > + Add a bucket + </SubPageHeader> + <form onSubmit={handleSubmit} ref={formRef} className={classes.fields}> + <PrimaryForm className={classes.card} /> + <MetadataForm className={classes.card} /> + <IndexingAndNotificationsForm className={classes.card} settings={settings} /> + <PreviewForm className={classes.card} /> + <input type="submit" style={{ display: 'none' }} /> + </form> + <FormActions siblingRef={formRef}> + {submitFailed && ( + <Form.FormError + className={classes.error} + error={error || submitError} + errors={{ + unexpected: 'Something went wrong', + notificationConfigurationError: 'Notification configuration error', + insufficientPermissions: 'Insufficient permissions', + subscriptionInvalid: 'Subscription invalid', + }} + margin="none" + /> + )} {submitting && ( <Delay> {() => ( @@ -823,7 +1440,7 @@ function Add({ close }: AddProps) { </Delay> )} <M.Button - onClick={() => close('cancel')} + onClick={() => back('cancel')} color="primary" disabled={submitting} > @@ -833,10 +1450,11 @@ function Add({ close }: AddProps) { onClick={handleSubmit} color="primary" disabled={submitting || (submitFailed && hasValidationErrors)} + variant="contained" > Add </M.Button> - </M.DialogActions> + </FormActions> </> )} </RF.Form> @@ -973,48 +1591,72 @@ function Reindex({ bucket, open, close }: ReindexProps) { ) } +interface BucketFieldSkeletonProps { + className: string + width: number +} + +function BucketFieldSkeleton({ className, width }: BucketFieldSkeletonProps) { + return <Card className={className} title={<Skeleton height={32} width={width} />} /> +} + +interface CardsPlaceholderProps { + className: string +} + +function CardsPlaceholder({ className }: CardsPlaceholderProps) { + const classes = useStyles() + return ( + <div className={className}> + <BucketFieldSkeleton className={classes.card} width={300} /> + <BucketFieldSkeleton className={classes.card} width={100} /> + <BucketFieldSkeleton className={classes.card} width={240} /> + <BucketFieldSkeleton className={classes.card} width={180} /> + </div> + ) +} + +interface EditPageSkeletonProps { + back: () => void +} + +function EditPageSkeleton({ back }: EditPageSkeletonProps) { + const classes = useStyles() + return ( + <> + <SubPageHeader back={back} submit={noop} /> + <CardsPlaceholder className={classes.fields} /> + </> + ) +} + interface EditProps { bucket: BucketConfig - close: (reason?: string) => void + back: (reason?: string) => void + submit: ( + input: Model.GQLTypes.BucketUpdateInput, + ) => Promise< + | Exclude<Model.GQLTypes.BucketUpdateResult, Model.GQLTypes.BucketUpdateSuccess> + | Error + | undefined + > } -function Edit({ bucket, close }: EditProps) { - const update = GQL.useMutation(UPDATE_MUTATION) - +function Edit({ bucket, back, submit }: EditProps) { const [reindexOpen, setReindexOpen] = React.useState(false) const openReindex = React.useCallback(() => setReindexOpen(true), []) const closeReindex = React.useCallback(() => setReindexOpen(false), []) + const classes = useStyles() + const onSubmit = React.useCallback( async (values) => { try { const input = R.applySpec(editFormSpec)(values) - const { bucketUpdate: r } = await update({ name: bucket.name, input }) - switch (r.__typename) { - case 'BucketUpdateSuccess': - close() - return undefined - case 'SnsInvalid': - // shouldnt happen since we're validating it - return { snsNotificationArn: 'invalidArn' } - case 'NotificationTopicNotFound': - return { snsNotificationArn: 'topicNotFound' } - case 'NotificationConfigurationError': - return { - snsNotificationArn: 'configurationError', - [FF.FORM_ERROR]: 'notificationConfigurationError', - } - case 'BucketNotFound': - return { [FF.FORM_ERROR]: 'bucketNotFound' } - case 'BucketIndexContentBytesInvalid': - // shouldnt happen since we valide input - return { indexContentBytes: 'integerInRange' } - case 'BucketFileExtensionsToIndexInvalid': - // shouldnt happen since we valide input - return { fileExtensionsToIndex: 'validExtensions' } - default: - return assertNever(r) - } + const error = await submit(input) + if (!error) return + if (error instanceof Error) throw error + return parseResponseError(error) } catch (e) { // eslint-disable-next-line no-console console.error('Error updating bucket') @@ -1023,29 +1665,12 @@ function Edit({ bucket, close }: EditProps) { return { [FF.FORM_ERROR]: 'unexpected' } } }, - [update, close, bucket.name], + [submit], ) - const initialValues = { - title: bucket.title, - iconUrl: bucket.iconUrl || '', - description: bucket.description || '', - relevanceScore: bucket.relevanceScore.toString(), - overviewUrl: bucket.overviewUrl || '', - tags: (bucket.tags || []).join(', '), - linkedData: bucket.linkedData ? JSON.stringify(bucket.linkedData) : '', - enableDeepIndexing: - !R.equals(bucket.fileExtensionsToIndex, []) && bucket.indexContentBytes !== 0, - fileExtensionsToIndex: (bucket.fileExtensionsToIndex || []).join(', '), - indexContentBytes: bucket.indexContentBytes, - scannerParallelShardsDepth: bucket.scannerParallelShardsDepth?.toString() || '', - snsNotificationArn: - bucket.snsNotificationArn === DO_NOT_SUBSCRIBE_STR - ? DO_NOT_SUBSCRIBE_SYM - : bucket.snsNotificationArn, - skipMetaDataIndexing: bucket.skipMetaDataIndexing ?? false, - browsable: bucket.browsable ?? false, - } + const initialValues = bucketToFormValues(bucket) + + const formRef = React.useRef<HTMLFormElement>(null) return ( <RF.Form onSubmit={onSubmit} initialValues={initialValues}> @@ -1061,340 +1686,199 @@ function Edit({ bucket, close }: EditProps) { }) => ( <> <Reindex bucket={bucket.name} open={reindexOpen} close={closeReindex} /> - <M.DialogTitle>Edit the "{bucket.name}" bucket</M.DialogTitle> - <M.DialogContent> - <React.Suspense fallback={<BucketFieldsPlaceholder />}> - <form onSubmit={handleSubmit}> - <BucketFields bucket={bucket} reindex={openReindex} /> - {submitFailed && ( - <Form.FormError - error={error || submitError} - errors={{ - unexpected: 'Something went wrong', - notificationConfigurationError: 'Notification configuration error', - bucketNotFound: 'Bucket not found', - }} - /> - )} - <input type="submit" style={{ display: 'none' }} /> - </form> - </React.Suspense> - </M.DialogContent> - <M.DialogActions> - {submitting && ( - <Delay> - {() => ( - <M.Box flexGrow={1} display="flex" pl={2}> - <M.CircularProgress size={24} /> - </M.Box> - )} - </Delay> - )} - <M.Button - onClick={() => form.reset()} - color="primary" - disabled={pristine || submitting} - > - Reset - </M.Button> - <M.Button - onClick={() => close('cancel')} - color="primary" - disabled={submitting} - > - Cancel - </M.Button> - <M.Button - onClick={handleSubmit} - color="primary" - disabled={pristine || submitting || (submitFailed && hasValidationErrors)} - > - Save - </M.Button> - </M.DialogActions> + <SubPageHeader + back={back} + dirty={!pristine} + disabled={submitting} + submit={handleSubmit} + /> + <React.Suspense fallback={<CardsPlaceholder className={classes.fields} />}> + <form className={classes.fields} onSubmit={handleSubmit} ref={formRef}> + <PrimaryCard bucket={bucket} className={classes.card} form={form} /> + <MetadataCard bucket={bucket} className={classes.card} form={form} /> + <IndexingAndNotificationsCard + bucket={bucket} + className={classes.card} + form={form} + reindex={openReindex} + /> + <PreviewCard bucket={bucket} className={classes.card} form={form} /> + <input type="submit" style={{ display: 'none' }} /> + </form> + </React.Suspense> + {!bucket && ( + <FormActions siblingRef={formRef}> + {submitFailed && ( + <Form.FormError + className={classes.error} + error={error || submitError} + errors={{ + unexpected: 'Something went wrong', + notificationConfigurationError: 'Notification configuration error', + bucketNotFound: 'Bucket not found', + }} + margin="none" + /> + )} + {submitting && ( + <Delay> + {() => ( + <M.Box flexGrow={1} display="flex" pl={2}> + <M.CircularProgress size={24} /> + </M.Box> + )} + </Delay> + )} + <M.Button + onClick={() => form.reset()} + color="primary" + disabled={pristine || submitting} + > + Reset + </M.Button> + <M.Button + onClick={() => back('cancel')} + color="primary" + disabled={submitting} + > + Cancel + </M.Button> + <M.Button + onClick={handleSubmit} + color="primary" + disabled={pristine || submitting || (submitFailed && hasValidationErrors)} + variant="contained" + > + Save + </M.Button> + </FormActions> + )} </> )} </RF.Form> ) } -interface DeleteProps { - bucket: BucketConfig - close: (reason?: string) => void +interface EditRouteParams { + bucketName: string } -function Delete({ bucket, close }: DeleteProps) { - const { push } = Notifications.use() - const t = useTracker() - const rm = GQL.useMutation(REMOVE_MUTATION) - const doDelete = React.useCallback(async () => { - close() - try { - const { bucketRemove: r } = await rm({ name: bucket.name }) - switch (r.__typename) { - case 'BucketRemoveSuccess': - t.track('WEB', { type: 'admin', action: 'bucket delete', bucket: bucket.name }) - return - case 'IndexingInProgress': - push(`Can't delete bucket "${bucket.name}" while it's being indexed`) - return - case 'BucketNotFound': - push(`Can't delete bucket "${bucket.name}": not found`) - return - default: - assertNever(r) - } - } catch (e) { - push(`Error deleting bucket "${bucket.name}"`) - // eslint-disable-next-line no-console - console.error('Error deleting bucket') - // eslint-disable-next-line no-console - console.error(e) - } - }, [bucket, close, rm, push, t]) +interface EditPageProps { + back: () => void +} - return ( - <> - <M.DialogTitle>Delete a bucket</M.DialogTitle> - <M.DialogContent> - You are about to disconnect "{bucket.name}" from Quilt. The search index - will be deleted. Bucket contents will remain unchanged. - </M.DialogContent> - <M.DialogActions> - <M.Button onClick={() => close('cancel')} color="primary"> - Cancel - </M.Button> - <M.Button onClick={doDelete} color="primary"> - Delete - </M.Button> - </M.DialogActions> - </> +function EditPage({ back }: EditPageProps) { + const { bucketName } = RRDom.useParams<EditRouteParams>() + const { urls } = NamedRoutes.use() + const update = GQL.useMutation(UPDATE_MUTATION) + const { bucketConfigs: rows } = GQL.useQueryS(BUCKET_CONFIGS_QUERY) + const bucket = React.useMemo( + () => (bucketName ? rows.find(({ name }) => name === bucketName) : null), + [bucketName, rows], + ) + 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 + 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], ) + if (!bucket) return <RRDom.Redirect to={urls.adminBuckets()} /> + return <Edit bucket={bucket} back={back} submit={submit} /> } -const useCustomBucketIconStyles = M.makeStyles({ - stub: { - opacity: 0.7, - }, -}) - -interface CustomBucketIconProps { - src: string +interface AddPageProps { + back: () => void } -function CustomBucketIcon({ src }: CustomBucketIconProps) { - const classes = useCustomBucketIconStyles() - - return <BucketIcon alt="" classes={classes} src={src} title="Default icon" /> +function AddPage({ back }: AddPageProps) { + const data = GQL.useQueryS(CONTENT_INDEXING_SETTINGS_QUERY) + const settings = data.config.contentIndexingSettings + const add = GQL.useMutation(ADD_MUTATION) + const { push } = Notifications.use() + const { track } = useTracker() + const submit = React.useCallback( + async (input: Model.GQLTypes.BucketAddInput) => { + try { + const { bucketAdd: r } = await add({ input }) + 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], + ) + return <Add settings={settings} back={back} submit={submit} /> } -const columns: Table.Column<BucketConfig>[] = [ - { - id: 'name', - label: 'Name (relevance)', - getValue: R.prop('name'), - getDisplay: (v: string, b: BucketConfig) => ( - <span> - <M.Box fontFamily="monospace.fontFamily" component="span"> - {v} - </M.Box>{' '} - <M.Box color="text.secondary" component="span"> - ({b.relevanceScore}) - </M.Box> - </span> - ), - }, - { - id: 'icon', - label: 'Icon', - sortable: false, - align: 'center', - getValue: R.prop('iconUrl'), - getDisplay: (v: string) => <CustomBucketIcon src={v} />, - }, - { - id: 'title', - label: 'Title', - getValue: R.prop('title'), - getDisplay: (v: string) => ( - <M.Box - component="span" - maxWidth={240} - textOverflow="ellipsis" - overflow="hidden" - whiteSpace="nowrap" - display="inline-block" - > - {v} - </M.Box> - ), - }, - { - id: 'description', - label: 'Description', - getValue: R.prop('description'), - getDisplay: (v: string | undefined) => - v ? ( - <M.Box - component="span" - maxWidth={240} - textOverflow="ellipsis" - overflow="hidden" - whiteSpace="nowrap" - display="inline-block" - > - {v} - </M.Box> - ) : ( - <M.Box color="text.secondary" component="span"> - {'<Empty>'} - </M.Box> - ), - }, - { - id: 'lastIndexed', - label: 'Last indexed', - getValue: R.prop('lastIndexed'), - getDisplay: (v: Date | undefined) => - v ? ( - <span title={v.toLocaleString()}> - {dateFns.formatDistanceToNow(v, { addSuffix: true })} - </span> - ) : ( - <M.Box color="text.secondary" component="span"> - {'<N/A>'} - </M.Box> - ), - }, -] - -interface CRUDProps { - bucketName?: string +function useIsAddPage() { + const location = RRDom.useLocation() + const params = parseSearch(location.search) + return !!params.add } -function CRUD({ bucketName }: CRUDProps) { - const { bucketConfigs: rows } = GQL.useQueryS(BUCKET_CONFIGS_QUERY) - const filtering = Table.useFiltering({ - rows, - filterBy: ({ name, title }) => name + title, - }) - const ordering = Table.useOrdering({ - rows: filtering.filtered, - column: columns[0], - }) - const pagination = Pagination.use(ordering.ordered, { - // @ts-expect-error - getItemId: R.prop('name'), - }) - const { open: openDialog, render: renderDialogs } = Dialogs.use() - - const { urls } = NamedRoutes.use() - const history = RRDom.useHistory() - - const toolbarActions = [ - { - title: 'Add bucket', - icon: <M.Icon>add</M.Icon>, - fn: React.useCallback(() => { - openDialog(({ close }) => <Add {...{ close }} />) - }, [openDialog]), - }, - ] - - const edit = (bucket: BucketConfig) => () => { - history.push(urls.adminBuckets(bucket.name)) - } - - const inlineActions = (bucket: BucketConfig) => [ - { - title: 'Delete', - icon: <M.Icon>delete</M.Icon>, - fn: () => { - openDialog(({ close }) => <Delete {...{ bucket, close }} />) - }, - }, - { - title: 'Edit', - icon: <M.Icon>edit</M.Icon>, - fn: edit(bucket), - }, - ] - - const editingBucket = React.useMemo( - () => (bucketName ? rows.find(({ name }) => name === bucketName) : null), - [bucketName, rows], - ) - - const onBucketClose = React.useCallback(() => { - history.push(urls.adminBuckets()) - }, [history, urls]) +interface BucketsProps { + back: () => void +} - if (bucketName && !editingBucket) { - // Bucket name set in URL, but it was not found in buckets list - return <RRDom.Redirect to={urls.adminBuckets()} /> +function Buckets({ back }: BucketsProps) { + const isAddPage = useIsAddPage() + if (isAddPage) { + return ( + <React.Suspense fallback={<AddPageSkeleton back={back} />}> + <AddPage back={back} /> + </React.Suspense> + ) } - return ( - <M.Paper> - {renderDialogs({ maxWidth: 'xs', fullWidth: true })} - - <M.Dialog open={!!editingBucket} fullWidth maxWidth="xs"> - {editingBucket && <Edit bucket={editingBucket} close={onBucketClose} />} - </M.Dialog> - - <Table.Toolbar heading="Buckets" actions={toolbarActions}> - <Table.Filter {...filtering} /> - </Table.Toolbar> - <Table.Wrapper> - <M.Table size="small"> - <Table.Head columns={columns} ordering={ordering} withInlineActions /> - <M.TableBody> - {pagination.paginated.map((i: BucketConfig) => ( - <M.TableRow - hover - key={i.name} - onClick={edit(i)} - style={{ cursor: 'pointer' }} - > - {columns.map((col) => ( - <M.TableCell key={col.id} align={col.align} {...col.props}> - {(col.getDisplay || R.identity)(col.getValue(i), i)} - </M.TableCell> - ))} - <M.TableCell - align="right" - padding="none" - onClick={(e) => e.stopPropagation()} - > - <Table.InlineActions actions={inlineActions(i)} /> - </M.TableCell> - </M.TableRow> - ))} - </M.TableBody> - </M.Table> - </Table.Wrapper> - <Table.Pagination pagination={pagination} /> - </M.Paper> + <React.Suspense fallback={<ListPageSkeleton />}> + <ListPage /> + </React.Suspense> ) } -export default function Buckets() { - const location = RRDom.useLocation() - const { bucket } = parseSearch(location.search) - const bucketName = Array.isArray(bucket) ? bucket[0] : bucket +export default function BucketsRouter() { + const history = RRDom.useHistory() + const { paths, urls } = NamedRoutes.use() + const back = React.useCallback(() => history.push(urls.adminBuckets()), [history, urls]) + return ( <M.Box mt={2} mb={2}> <MetaTitle>{['Buckets', 'Admin']}</MetaTitle> - <React.Suspense - fallback={ - <M.Paper> - <Table.Toolbar heading="Buckets" /> - <Table.Progress /> - </M.Paper> - } - > - <CRUD bucketName={bucketName} /> - </React.Suspense> + <RRDom.Switch> + <RRDom.Route path={paths.adminBucketEdit} exact strict> + <React.Suspense fallback={<EditPageSkeleton back={back} />}> + <EditPage back={back} /> + </React.Suspense> + </RRDom.Route> + <RRDom.Route> + <Buckets back={back} /> + </RRDom.Route> + </RRDom.Switch> </M.Box> ) } diff --git a/catalog/app/containers/Admin/Buckets/List.tsx b/catalog/app/containers/Admin/Buckets/List.tsx new file mode 100644 index 00000000000..96016c676ea --- /dev/null +++ b/catalog/app/containers/Admin/Buckets/List.tsx @@ -0,0 +1,279 @@ +import * as dateFns from 'date-fns' +import * as R from 'ramda' +import * as React from 'react' +import * as RRDom from 'react-router-dom' +import * as M from '@material-ui/core' + +import BucketIcon from 'components/BucketIcon' +import * as Pagination from 'components/Pagination' +import * as Notifications from 'containers/Notifications' +import * as Dialogs from 'utils/Dialogs' +import * as GQL from 'utils/GraphQL' +import * as NamedRoutes from 'utils/NamedRoutes' +import assertNever from 'utils/assertNever' +import parseSearch from 'utils/parseSearch' +import { useTracker } from 'utils/tracking' + +import * as Table from '../Table' + +import { BucketConfigSelectionFragment as BucketConfig } from './gql/BucketConfigSelection.generated' +import BUCKET_CONFIGS_QUERY from './gql/BucketConfigs.generated' +import REMOVE_MUTATION from './gql/BucketsRemove.generated' + +export function ListSkeleton() { + return ( + <M.Paper> + <Table.Toolbar heading="Buckets" /> + <Table.Progress /> + </M.Paper> + ) +} + +interface DeleteProps { + bucket: BucketConfig + close: (reason?: string) => void +} + +function Delete({ bucket, close }: DeleteProps) { + const { push } = Notifications.use() + const { track } = useTracker() + const rm = GQL.useMutation(REMOVE_MUTATION) + const doDelete = React.useCallback(async () => { + close() + try { + const { bucketRemove: r } = await rm({ name: bucket.name }) + switch (r.__typename) { + case 'BucketRemoveSuccess': + track('WEB', { type: 'admin', action: 'bucket delete', bucket: bucket.name }) + return + case 'IndexingInProgress': + push(`Can't delete bucket "${bucket.name}" while it's being indexed`) + return + case 'BucketNotFound': + push(`Can't delete bucket "${bucket.name}": not found`) + return + default: + assertNever(r) + } + } catch (e) { + push(`Error deleting bucket "${bucket.name}"`) + // eslint-disable-next-line no-console + console.error('Error deleting bucket') + // eslint-disable-next-line no-console + console.error(e) + } + }, [bucket, close, rm, push, track]) + + return ( + <> + <M.DialogTitle>Delete a bucket</M.DialogTitle> + <M.DialogContent> + You are about to disconnect "{bucket.name}" from Quilt. The search index + will be deleted. Bucket contents will remain unchanged. + </M.DialogContent> + <M.DialogActions> + <M.Button onClick={() => close('cancel')} color="primary"> + Cancel + </M.Button> + <M.Button onClick={doDelete} color="primary"> + Delete + </M.Button> + </M.DialogActions> + </> + ) +} + +function useLegacyBucketNameParam() { + const location = RRDom.useLocation() + const params = parseSearch(location.search) + return Array.isArray(params.bucket) ? params.bucket[0] : params.bucket +} + +const useCustomBucketIconStyles = M.makeStyles({ + stub: { + opacity: 0.7, + }, +}) + +interface CustomBucketIconProps { + src: string +} + +function CustomBucketIcon({ src }: CustomBucketIconProps) { + const classes = useCustomBucketIconStyles() + + return <BucketIcon alt="" classes={classes} src={src} title="Default icon" /> +} + +const columns: Table.Column<BucketConfig>[] = [ + { + id: 'name', + label: 'Name (relevance)', + getValue: R.prop('name'), + getDisplay: (v: string, b: BucketConfig) => ( + <span> + <M.Box fontFamily="monospace.fontFamily" component="span"> + {v} + </M.Box>{' '} + <M.Box color="text.secondary" component="span"> + ({b.relevanceScore}) + </M.Box> + </span> + ), + }, + { + id: 'icon', + label: 'Icon', + sortable: false, + align: 'center', + getValue: R.prop('iconUrl'), + getDisplay: (v: string) => <CustomBucketIcon src={v} />, + }, + { + id: 'title', + label: 'Title', + getValue: R.prop('title'), + getDisplay: (v: string) => ( + <M.Box + component="span" + maxWidth={240} + textOverflow="ellipsis" + overflow="hidden" + whiteSpace="nowrap" + display="inline-block" + > + {v} + </M.Box> + ), + }, + { + id: 'description', + label: 'Description', + getValue: R.prop('description'), + getDisplay: (v: string | undefined) => + v ? ( + <M.Box + component="span" + maxWidth={240} + textOverflow="ellipsis" + overflow="hidden" + whiteSpace="nowrap" + display="inline-block" + > + {v} + </M.Box> + ) : ( + <M.Box color="text.secondary" component="span"> + {'<Empty>'} + </M.Box> + ), + }, + { + id: 'lastIndexed', + label: 'Last indexed', + getValue: R.prop('lastIndexed'), + getDisplay: (v: Date | undefined) => + v ? ( + <span title={v.toLocaleString()}> + {dateFns.formatDistanceToNow(v, { addSuffix: true })} + </span> + ) : ( + <M.Box color="text.secondary" component="span"> + {'<N/A>'} + </M.Box> + ), + }, +] + +export default function List() { + const { bucketConfigs: rows } = GQL.useQueryS(BUCKET_CONFIGS_QUERY) + const filtering = Table.useFiltering({ + rows, + filterBy: ({ name, title }) => name + title, + }) + const ordering = Table.useOrdering({ + rows: filtering.filtered, + column: columns[0], + }) + const pagination = Pagination.use(ordering.ordered, { + // @ts-expect-error + getItemId: R.prop('name'), + }) + const { open: openDialog, render: renderDialogs } = Dialogs.use() + + const { urls } = NamedRoutes.use() + const history = RRDom.useHistory() + + const toolbarActions = [ + { + title: 'Add bucket', + icon: <M.Icon>add</M.Icon>, + fn: React.useCallback(() => { + history.push(urls.adminBuckets({ add: true })) + }, [history, urls]), + }, + ] + + const bucketName = useLegacyBucketNameParam() + if (bucketName) { + return <RRDom.Redirect to={urls.adminBucketEdit(bucketName)} /> + } + + const edit = (bucket: BucketConfig) => () => { + history.push(urls.adminBucketEdit(bucket.name)) + } + + const inlineActions = (bucket: BucketConfig) => [ + { + title: 'Delete', + icon: <M.Icon>delete</M.Icon>, + fn: () => { + openDialog(({ close }) => <Delete {...{ bucket, close }} />) + }, + }, + { + title: 'Edit', + icon: <M.Icon>edit</M.Icon>, + fn: edit(bucket), + }, + ] + + return ( + <M.Paper> + {renderDialogs({ maxWidth: 'xs', fullWidth: true })} + + <Table.Toolbar heading="Buckets" actions={toolbarActions}> + <Table.Filter {...filtering} /> + </Table.Toolbar> + <Table.Wrapper> + <M.Table size="small"> + <Table.Head columns={columns} ordering={ordering} withInlineActions /> + <M.TableBody> + {pagination.paginated.map((bucket: BucketConfig) => ( + <M.TableRow + hover + key={bucket.name} + onClick={edit(bucket)} + style={{ cursor: 'pointer' }} + > + {columns.map((col) => ( + <M.TableCell key={col.id} align={col.align} {...col.props}> + {(col.getDisplay || R.identity)(col.getValue(bucket), bucket)} + </M.TableCell> + ))} + <M.TableCell + align="right" + padding="none" + onClick={(e) => e.stopPropagation()} + > + <Table.InlineActions actions={inlineActions(bucket)} /> + </M.TableCell> + </M.TableRow> + ))} + </M.TableBody> + </M.Table> + </Table.Wrapper> + <Table.Pagination pagination={pagination} /> + </M.Paper> + ) +} diff --git a/catalog/app/containers/Admin/Form.tsx b/catalog/app/containers/Admin/Form.tsx index be0edabb2c0..814d3624bcc 100644 --- a/catalog/app/containers/Admin/Form.tsx +++ b/catalog/app/containers/Admin/Form.tsx @@ -76,7 +76,8 @@ export function Checkbox({ const useFormErrorStyles = M.makeStyles((t) => ({ root: { - marginTop: t.spacing(3), + marginTop: ({ margin }: { margin: 'normal' | 'none' }) => + t.spacing(margin === 'normal' ? 3 : 0), '& a': { textDecoration: 'underline', @@ -87,10 +88,11 @@ const useFormErrorStyles = M.makeStyles((t) => ({ interface FormErrorProps extends M.TypographyProps { error?: string errors: ErrorMessageMap + margin?: 'normal' | 'none' } -export function FormError({ error, errors, ...rest }: FormErrorProps) { - const classes = useFormErrorStyles() +export function FormError({ error, errors, margin = 'normal', ...rest }: FormErrorProps) { + const classes = useFormErrorStyles({ margin }) if (!error) return null return ( <M.Typography color="error" classes={classes} {...rest}> diff --git a/catalog/app/containers/Bucket/Overview.js b/catalog/app/containers/Bucket/Overview.js index 2670460238a..3acef4e2ccb 100644 --- a/catalog/app/containers/Bucket/Overview.js +++ b/catalog/app/containers/Bucket/Overview.js @@ -812,7 +812,7 @@ function Head({ s3, overviewUrl, bucket, description }) { /> </M.Box> {isAdmin && ( - <RRLink className={classes.settings} to={urls.adminBuckets(bucket)}> + <RRLink className={classes.settings} to={urls.adminBucketEdit(bucket)}> <M.IconButton color="inherit"> <M.Icon>settings</M.Icon> </M.IconButton> diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 36727a8029d..c044596038a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,8 +11,6 @@ Entries inside each section should be ordered by type: ## Python API ## CLI - -## Catalog, Lambdas !--> # 6.0.0 - 2024-08-19