/** @jsxRuntime classic */ /** @jsx jsx */ import copyToClipboard from 'clipboard-copy' import { useRouter } from 'next/router' import { Fragment, type HTMLAttributes, memo, type ReactElement, useCallback, useEffect, useMemo, useRef, useState, } from 'react' import { Button } from '@keystone-ui/button' import { Box, Center, Stack, Text, jsx, useTheme } from '@keystone-ui/core' import { LoadingDots } from '@keystone-ui/loading' import { ClipboardIcon } from '@keystone-ui/icons/icons/ClipboardIcon' import { AlertDialog } from '@keystone-ui/modals' import { Notice } from '@keystone-ui/notice' import { useToasts } from '@keystone-ui/toast' import { Tooltip } from '@keystone-ui/tooltip' import { FieldLabel, TextInput } from '@keystone-ui/fields' import type { ListMeta, FieldMeta } from '../../../../types' import { type DataGetter, type DeepNullable, makeDataGetter, deserializeValue, type ItemData, useInvalidFields, Fields, useChangedFieldsAndDataForUpdate, } from '../../../../admin-ui/utils' import { gql, useMutation, useQuery } from '../../../../admin-ui/apollo' import { useList } from '../../../../admin-ui/context' import { PageContainer, HEADER_HEIGHT } from '../../../../admin-ui/components/PageContainer' import { GraphQLErrorNotice } from '../../../../admin-ui/components/GraphQLErrorNotice' import { usePreventNavigation } from '../../../../admin-ui/utils/usePreventNavigation' import { CreateButtonLink } from '../../../../admin-ui/components/CreateButtonLink' import { BaseToolbar, ColumnLayout, ItemPageHeader } from './common' type ItemPageProps = { listKey: string } function useEventCallback any>(callback: Func): Func { const callbackRef = useRef(callback) const cb = useCallback((...args: any[]) => { return callbackRef.current(...args) }, []) useEffect(() => { callbackRef.current = callback }) return cb as any } type ItemViewFieldModes = NonNullable type ItemViewFieldPositions = NonNullable function ItemForm ({ listKey, itemGetter, selectedFields, fieldModes, fieldPositions, showDelete, item, }: { listKey: string itemGetter: DataGetter selectedFields: string fieldModes: Record fieldPositions: Record showDelete: boolean item: ItemData }) { const list = useList(listKey) const { spacing, typography } = useTheme() const [update, { loading, error, data }] = useMutation( gql`mutation ($data: ${list.gqlNames.updateInputName}!, $id: ID!) { item: ${list.gqlNames.updateMutationName}(where: { id: $id }, data: $data) { ${selectedFields} } }`, { errorPolicy: 'all' } ) itemGetter = useMemo(() => { if (data) { return makeDataGetter(data, error?.graphQLErrors).get('item') } }, [data, error]) ?? itemGetter const [state, setValue] = useState(() => { const value = deserializeValue(list.fields, itemGetter) return { value, item: itemGetter } }) if ( !loading && state.item.data !== itemGetter.data && (itemGetter.errors || []).every(x => x.path?.length !== 1) ) { const value = deserializeValue(list.fields, itemGetter) setValue({ value, item: itemGetter }) } const { changedFields, dataForUpdate } = useChangedFieldsAndDataForUpdate( list.fields, state.item, state.value ) const invalidFields = useInvalidFields(list.fields, state.value) const [forceValidation, setForceValidation] = useState(false) const toasts = useToasts() const onSave = useEventCallback(() => { const newForceValidation = invalidFields.size !== 0 setForceValidation(newForceValidation) if (newForceValidation) return update({ variables: { data: dataForUpdate, id: state.item.get('id').data } }) // TODO -- Experimenting with less detail in the toasts, so the data lines are commented // out below. If we're happy with this, clean up the unused lines. .then(({ /* data, */ errors }) => { // we're checking for path being undefined OR path.length === 1 because errors with a path larger than 1 will // be field level errors which are handled seperately and do not indicate a failure to // update the item, path being undefined generally indicates a failure in the graphql mutation itself - ie a type error const error = errors?.find(x => x.path === undefined || x.path?.length === 1) if (error) { toasts.addToast({ title: 'Failed to update item', tone: 'negative', message: error.message, }) } else { toasts.addToast({ // title: data.item[list.labelField] || data.item.id, tone: 'positive', title: 'Saved successfully', // message: 'Saved successfully', }) } }) .catch(err => { toasts.addToast({ title: 'Failed to update item', tone: 'negative', message: err.message }) }) }) const labelFieldValue = list.isSingleton ? list.label : state.item.data?.[list.labelField] const itemId = state.item.data?.id const hasChangedFields = !!changedFields.size usePreventNavigation(useMemo(() => ({ current: hasChangedFields }), [hasChangedFields])) return ( x.path?.length === 1)} /> { setValue(state => ({ item: state.item, value: value(state.value) })) }, [setValue] )} value={state.value} /> { setValue(state => ({ item: state.item, value: deserializeValue(list.fields, state.item), })) })} loading={loading} deleteButton={useMemo( () => showDelete ? ( ) : undefined, [showDelete, list, labelFieldValue, itemId] )} /> Item ID
{props => ( )}
{ setValue(state => ({ item: state.item, value: value(state.value) })) }, [setValue] )} value={state.value} />
) } function DeleteButton ({ itemLabel, itemId, list, }: { itemLabel: string itemId: string list: ListMeta }) { const toasts = useToasts() const [deleteItem, { loading }] = useMutation( gql`mutation ($id: ID!) { ${list.gqlNames.deleteMutationName}(where: { id: $id }) { id } }`, { variables: { id: itemId } } ) const [isOpen, setIsOpen] = useState(false) const router = useRouter() return ( { try { await deleteItem() } catch (err: any) { return toasts.addToast({ title: `Failed to delete ${list.singular} item: ${itemLabel}`, message: err.message, tone: 'negative', }) } router.push(list.isSingleton ? '/' : `/${list.path}`) return toasts.addToast({ title: itemLabel, message: `Deleted ${list.singular} item successfully`, tone: 'positive', }) }, loading, }, cancel: { label: 'Cancel', action: () => { setIsOpen(false) }, }, }} > Are you sure you want to delete {itemLabel}? ) } export const getItemPage = (props: ItemPageProps) => () => function ItemPage ({ listKey }: ItemPageProps) { const list = useList(listKey) const id = useRouter().query.id as string const { query, selectedFields } = useMemo(() => { const selectedFields = Object.entries(list.fields) .filter(([fieldKey, field]) => { if (fieldKey === 'id') return true return field.itemView.fieldMode !== 'hidden' }) .map(([fieldKey]) => { return list.fields[fieldKey].controller.graphqlSelection }) .join('\n') return { selectedFields, query: gql` query ItemPage($id: ID!, $listKey: String!) { item: ${list.gqlNames.itemQueryName}(where: {id: $id}) { ${selectedFields} } keystone { adminMeta { list(key: $listKey) { hideCreate hideDelete fields { path itemView(id: $id) { fieldMode fieldPosition } } } } } } `, } }, [list]) const { data, error, loading } = useQuery(query, { variables: { id, listKey }, errorPolicy: 'all', skip: id === undefined, }) const dataGetter = makeDataGetter< DeepNullable<{ item: ItemData keystone: { adminMeta: { list: { fields: { path: string itemView: { fieldMode: ItemViewFieldModes fieldPosition: ItemViewFieldPositions } }[] } } } }> >(data, error?.graphQLErrors) const itemViewFieldModesByField = useMemo(() => { const itemViewFieldModesByField: Record = {} dataGetter.data?.keystone?.adminMeta?.list?.fields?.forEach(field => { if (field === null || field.path === null || field?.itemView?.fieldMode == null) return itemViewFieldModesByField[field.path] = field.itemView.fieldMode }) return itemViewFieldModesByField }, [dataGetter.data?.keystone?.adminMeta?.list?.fields]) const itemViewFieldPositionsByField = useMemo(() => { const itemViewFieldPositionsByField: Record = {} dataGetter.data?.keystone?.adminMeta?.list?.fields?.forEach(field => { if (field === null || field.path === null || field?.itemView?.fieldPosition == null) return itemViewFieldPositionsByField[field.path] = field.itemView.fieldPosition }) return itemViewFieldPositionsByField }, [dataGetter.data?.keystone?.adminMeta?.list?.fields]) const pageLoading = loading || id === undefined const metaQueryErrors = dataGetter.get('keystone').errors const pageTitle: string = list.isSingleton ? list.label : pageLoading ? undefined : (data && data.item && (data.item[list.labelField] || data.item.id)) || id return ( } > {pageLoading ? (
) : metaQueryErrors ? ( {metaQueryErrors[0].message} ) : ( {data?.item == null ? ( {error?.graphQLErrors.length || error?.networkError ? ( ) : list.isSingleton ? ( id === '1' ? ( {list.label} doesn't exist or you don't have access to it. {!data.keystone.adminMeta.list!.hideCreate && } ) : ( The item with id "{id}" does not exist ) ) : ( The item with id "{id}" could not be found or you don't have access to it. )} ) : ( } item={data.item} /> )} )}
) } // Styled Components // ------------------------------ const Toolbar = memo(function Toolbar ({ hasChangedFields, loading, onSave, onReset, deleteButton, }: { hasChangedFields: boolean loading: boolean onSave: () => void onReset: () => void deleteButton?: ReactElement }) { return ( {hasChangedFields ? ( ) : ( No changes )} {deleteButton} ) }) function ResetChangesButton (props: { onReset: () => void }) { const [isConfirmModalOpen, setConfirmModalOpen] = useState(false) return ( props.onReset(), label: 'Reset changes', }, cancel: { action: () => setConfirmModalOpen(false), label: 'Cancel', }, }} isOpen={isConfirmModalOpen} title="Are you sure you want to reset changes?" tone="negative" > {null} ) } function StickySidebar (props: HTMLAttributes) { const { spacing } = useTheme() return (
) }