diff --git a/.changeset/thirty-apples-cry.md b/.changeset/thirty-apples-cry.md index 4405bf076fc..cfe8b30e30e 100644 --- a/.changeset/thirty-apples-cry.md +++ b/.changeset/thirty-apples-cry.md @@ -1,5 +1,6 @@ --- '@keystone-6/core': patch +'@keystone-6/fields-document': patch --- Fix relationship fields not using their `ui.labelField` configuration diff --git a/.changeset/thirty-strawberries-kick.md b/.changeset/thirty-strawberries-kick.md new file mode 100644 index 00000000000..0458b25e5ab --- /dev/null +++ b/.changeset/thirty-strawberries-kick.md @@ -0,0 +1,5 @@ +--- +'@keystone-6/core': minor +--- + +Adds `ui.searchFields` for the relationship field diff --git a/docs/pages/docs/fields/relationship.md b/docs/pages/docs/fields/relationship.md index db4a485c238..02c197ade6a 100644 --- a/docs/pages/docs/fields/relationship.md +++ b/docs/pages/docs/fields/relationship.md @@ -16,6 +16,9 @@ Read our [relationships guide](../guides/relationships) for details on Keystone - `displayMode` (default: `'select'`): Controls the mode used to display the field in the item view. The mode `'select'` displays related items in a select component, while `'cards'` displays the related items in a card layout. Each display mode supports further configuration. - `ui.displayMode === 'select'` options: - `labelField`: The field path from the related list to use for item labels in the select. Defaults to the `labelField` configured on the related list. +{% if $nextRelease %} + - `searchFields`: The fields used by the UI to search for this item, in context of this relationship field. Defaults to `searchFields` configured on the related list. +{% /if %} - `ui.displayMode === 'cards'` options: - `cardFields`: A list of field paths from the related list to render in the card component. Defaults to `'id'` and the `labelField` configured on the related list. - `linkToItem` (default `false`): If `true`, the default card component will render as a link to navigate to the related item. @@ -23,6 +26,11 @@ Read our [relationships guide](../guides/relationships) for details on Keystone - `inlineCreate` (default: `null`): If not `null`, an object of the form `{ fields: [...] }`, where `fields` is a list of field paths from the related list should be provided. An inline `Create` button will be included in the cards allowing a new related item to be created based on the configured field paths. - `inlineEdit` (default: `null`): If not `null`, an object of the form `{ fields: [...] }`, where `fields` is a list of field paths from the related list should be provided. An `Edit` button will be included in each card, allowing the configured fields to be edited for each related item. - `inlineConnect` (default: `false`): If `true`, an inline `Link existing item` button will be present, allowing existing items of the related list to be connected in this field. +{% if $nextRelease %} + Alternatively this can be an object with the properties: + - `labelField`: The field path from the related list to use for item labels in select. Defaults to the `labelField` configured on the related list. + - `searchFields`: The fields used by the UI to search for this item, in context of this relationship field. Defaults to `searchFields` configured on the related list. +{% /if %} - `ui.displayMode === 'count'` only supports `many` relationships ```typescript diff --git a/packages/core/src/___internal-do-not-use-will-break-in-patch/admin-ui/pages/ListPage/index.tsx b/packages/core/src/___internal-do-not-use-will-break-in-patch/admin-ui/pages/ListPage/index.tsx index dd583921c09..895c52ee385 100644 --- a/packages/core/src/___internal-do-not-use-will-break-in-patch/admin-ui/pages/ListPage/index.tsx +++ b/packages/core/src/___internal-do-not-use-will-break-in-patch/admin-ui/pages/ListPage/index.tsx @@ -137,14 +137,14 @@ const ListPage = ({ listKey }: ListPageProps) => { const { resetToDefaults } = useQueryParamsFromLocalStorage(listKey); - let currentPage = + const currentPage = typeof query.page === 'string' && !Number.isNaN(parseInt(query.page)) ? Number(query.page) : 1; - let pageSize = + const pageSize = typeof query.pageSize === 'string' && !Number.isNaN(parseInt(query.pageSize)) ? parseInt(query.pageSize) : list.pageSize; - let metaQuery = useQuery(listMetaGraphqlQuery, { variables: { listKey } }); + const metaQuery = useQuery(listMetaGraphqlQuery, { variables: { listKey } }); let { listViewFieldModesByField, filterableFields, orderableFields } = useMemo(() => { const listViewFieldModesByField: Record<string, 'read' | 'hidden'> = {}; @@ -166,13 +166,12 @@ const ListPage = ({ listKey }: ListPageProps) => { const sort = useSort(list, orderableFields); const filters = useFilters(list, filterableFields); - const searchFields = Object.values(list.fields) - .filter(({ search }) => search !== null) - .map(({ label }) => label); + const searchFields = Object.keys(list.fields).filter(key => list.fields[key].search); + const searchLabels = searchFields.map(key => list.fields[key].label); const searchParam = typeof query.search === 'string' ? query.search : ''; const [searchString, updateSearchString] = useState(searchParam); - const search = useFilter(searchParam, list); + const search = useFilter(searchParam, list, searchFields); const updateSearch = (value: string) => { const { search, ...queries } = query; @@ -283,7 +282,7 @@ const ListPage = ({ listKey }: ListPageProps) => { autoFocus value={searchString} onChange={e => updateSearchString(e.target.value)} - placeholder={`Search by ${searchFields.length ? searchFields.join(', ') : 'ID'}`} + placeholder={`Search by ${searchLabels.length ? searchLabels.join(', ') : 'ID'}`} /> <Button css={{ borderRadius: '0px 4px 4px 0px' }} type="submit"> <SearchIcon /> diff --git a/packages/core/src/admin-ui/system/createAdminMeta.ts b/packages/core/src/admin-ui/system/createAdminMeta.ts index 21a813fb550..e8d803ddd0d 100644 --- a/packages/core/src/admin-ui/system/createAdminMeta.ts +++ b/packages/core/src/admin-ui/system/createAdminMeta.ts @@ -1,8 +1,6 @@ import path from 'path'; -import { GraphQLString, isInputObjectType } from 'graphql'; import { KeystoneConfig, - QueryMode, MaybePromise, MaybeSessionFunction, BaseListTypeInfo, @@ -17,6 +15,10 @@ import { FilterOrderArgs } from '../../types/config/fields'; type ContextFunction<Return> = (context: KeystoneContext) => MaybePromise<Return>; export type FieldMetaRootVal = { + key: string; + /** + * @deprecated use .key, not .path + */ path: string; label: string; description: string | null; @@ -47,7 +49,8 @@ export type ListMetaRootVal = { pageSize: number; labelField: string; initialSort: { field: string; direction: 'ASC' | 'DESC' } | null; - fields: Array<FieldMetaRootVal>; + fields: FieldMetaRootVal[]; + fieldsByKey: Record<string, FieldMetaRootVal>; itemQueryName: string; listQueryName: string; description: string | null; @@ -58,7 +61,7 @@ export type ListMetaRootVal = { }; export type AdminMetaRootVal = { - lists: Array<ListMetaRootVal>; + lists: ListMetaRootVal[]; listsByKey: Record<string, ListMetaRootVal>; views: string[]; isAccessAllowed: undefined | ((context: KeystoneContext) => MaybePromise<boolean>); @@ -78,22 +81,12 @@ export function createAdminMeta( const omittedLists: string[] = []; - for (const [key, list] of Object.entries(initialisedLists)) { - const listConfig = lists[key]; + for (const [listKey, list] of Object.entries(initialisedLists)) { + const listConfig = lists[listKey]; if (list.graphql.isEnabled.query === false) { - omittedLists.push(key); + omittedLists.push(listKey); continue; } - // Default the labelField to `name`, `label`, or `title` if they exist; otherwise fall back to `id` - const labelField = - (listConfig.ui?.labelField as string | undefined) ?? - (listConfig.fields.label - ? 'label' - : listConfig.fields.name - ? 'name' - : listConfig.fields.title - ? 'title' - : 'id'); let initialColumns: string[]; if (listConfig.ui?.listView?.initialColumns) { @@ -104,10 +97,10 @@ export function createAdminMeta( // 2 more fields to the right of that. We don't include the 'id' field // unless it happened to be the labelField initialColumns = [ - labelField, + list.ui.labelField, ...Object.keys(list.fields) .filter(fieldKey => list.fields[fieldKey].graphql.isEnabled.read) - .filter(fieldKey => fieldKey !== labelField) + .filter(fieldKey => fieldKey !== list.ui.labelField) .filter(fieldKey => fieldKey !== 'id'), ].slice(0, 3); } @@ -116,15 +109,17 @@ export function createAdminMeta( listConfig.ui?.listView?.pageSize ?? 50, (list.types.findManyArgs.take.defaultValue ?? Infinity) as number ); - adminMetaRoot.listsByKey[key] = { - key, - labelField, + + adminMetaRoot.listsByKey[listKey] = { + key: listKey, + labelField: list.ui.labelField, description: listConfig.ui?.description ?? listConfig.description ?? null, label: list.adminUILabels.label, singular: list.adminUILabels.singular, plural: list.adminUILabels.plural, path: list.adminUILabels.path, fields: [], + fieldsByKey: {}, pageSize: maximumPageSize, initialColumns, initialSort: @@ -132,7 +127,7 @@ export function createAdminMeta( | { field: string; direction: 'ASC' | 'DESC' } | undefined) ?? null, // TODO: probably remove this from the GraphQL schema and here - itemQueryName: key, + itemQueryName: listKey, listQueryName: list.pluralGraphQLName, hideCreate: normalizeMaybeSessionFunction( list.graphql.isEnabled.create ? listConfig.ui?.hideCreate ?? false : false @@ -143,7 +138,7 @@ export function createAdminMeta( isHidden: normalizeMaybeSessionFunction(listConfig.ui?.isHidden ?? false), isSingleton: list.isSingleton, }; - adminMetaRoot.lists.push(adminMetaRoot.listsByKey[key]); + adminMetaRoot.lists.push(adminMetaRoot.listsByKey[listKey]); } let uniqueViewCount = -1; const stringViewsToIndex: Record<string, number> = {}; @@ -156,34 +151,10 @@ export function createAdminMeta( adminMetaRoot.views.push(view); return uniqueViewCount; } - // Populate .fields array - for (const [key, list] of Object.entries(initialisedLists)) { - if (omittedLists.includes(key)) continue; - const searchFields = new Set(config.lists[key].ui?.searchFields ?? []); - if (searchFields.has('id')) { - throw new Error( - `The ui.searchFields option on the ${key} list includes 'id'. Lists can always be searched by an item's id so it must not be specified as a search field` - ); - } - const whereInputFields = list.types.where.graphQLType.getFields(); - const possibleSearchFields = new Map<string, 'default' | 'insensitive' | null>(); - for (const fieldKey of Object.keys(list.fields)) { - const filterType = whereInputFields[fieldKey]?.type; - const fieldFilterFields = isInputObjectType(filterType) ? filterType.getFields() : undefined; - if (fieldFilterFields?.contains?.type === GraphQLString) { - possibleSearchFields.set( - fieldKey, - fieldFilterFields?.mode?.type === QueryMode.graphQLType ? 'insensitive' : 'default' - ); - } - } - if (config.lists[key].ui?.searchFields === undefined) { - const labelField = adminMetaRoot.listsByKey[key].labelField; - if (possibleSearchFields.has(labelField)) { - searchFields.add(labelField); - } - } + // populate .fields array + for (const [listKey, list] of Object.entries(initialisedLists)) { + if (omittedLists.includes(listKey)) continue; for (const [fieldKey, field] of Object.entries(list.fields)) { // If the field is a relationship field and is related to an omitted list, skip. @@ -191,30 +162,26 @@ export function createAdminMeta( // Disabling this entirely for now until we properly decide what the Admin UI // should do when `omit: ['read']` is used. if (field.graphql.isEnabled.read === false) continue; - let search = searchFields.has(fieldKey) ? possibleSearchFields.get(fieldKey) ?? null : null; - if (searchFields.has(fieldKey) && search === null) { - throw new Error( - `The ui.searchFields option on the ${key} list includes '${fieldKey}' but that field doesn't have a contains filter that accepts a GraphQL String` - ); - } + assertValidView( field.views, - `The \`views\` on the implementation of the field type at lists.${key}.fields.${fieldKey}` + `The \`views\` on the implementation of the field type at lists.${listKey}.fields.${fieldKey}` ); + const baseOrderFilterArgs = { fieldKey, listKey: list.listKey }; - adminMetaRoot.listsByKey[key].fields.push({ + const fieldMeta = { + key: fieldKey, label: field.label ?? humanize(fieldKey), description: field.ui?.description ?? null, viewsIndex: getViewId(field.views), customViewsIndex: field.ui?.views === undefined ? null - : (assertValidView(field.views, `lists.${key}.fields.${fieldKey}.ui.views`), + : (assertValidView(field.views, `lists.${listKey}.fields.${fieldKey}.ui.views`), getViewId(field.ui.views)), fieldMeta: null, - path: fieldKey, - listKey: key, - search, + listKey: listKey, + search: list.ui.searchableFields.get(fieldKey) ?? null, createView: { fieldMode: normalizeMaybeSessionFunction( field.graphql.isEnabled.create ? field.ui?.createView?.fieldMode ?? 'edit' : 'hidden' @@ -237,7 +204,13 @@ export function createAdminMeta( field.input?.orderBy ? field.graphql.isEnabled.orderBy : false, baseOrderFilterArgs ), - }); + + // DEPRECATED + path: fieldKey, + }; + + adminMetaRoot.listsByKey[listKey].fields.push(fieldMeta); + adminMetaRoot.listsByKey[listKey].fieldsByKey[fieldKey] = fieldMeta; } } diff --git a/packages/core/src/fields/types/relationship/index.ts b/packages/core/src/fields/types/relationship/index.ts index da21390093d..95a501f446e 100644 --- a/packages/core/src/fields/types/relationship/index.ts +++ b/packages/core/src/fields/types/relationship/index.ts @@ -12,6 +12,7 @@ type SelectDisplayConfig = { * Defaults to the labelField configured on the related list. */ labelField?: string; + searchFields?: string[]; }; }; @@ -30,7 +31,16 @@ type CardsDisplayConfig = { /** Configures inline edit mode for cards */ inlineEdit?: { fields: readonly string[] }; /** Configures whether a select to add existing items should be shown or not */ - inlineConnect?: boolean; + inlineConnect?: + | boolean + | { + /** + * The path of the field to use from the related list for item labels in the inline connect + * Defaults to the labelField configured on the related list. + */ + labelField: string; + searchFields?: string[]; + }; }; }; @@ -75,31 +85,37 @@ export const relationship = ref, ...config }: RelationshipFieldConfig<ListTypeInfo>): FieldTypeFunc<ListTypeInfo> => - meta => { + ({ fieldKey, listKey, lists }) => { const { many = false } = config; const [foreignListKey, foreignFieldKey] = ref.split('.'); + const foreignList = lists[foreignListKey]; + if (!foreignList) { + throw new Error( + `Unable to resolve list '${foreignListKey}' for field ${listKey}.${fieldKey}` + ); + } + const foreignListTypes = foreignList.types; + const commonConfig = { ...config, views: '@keystone-6/core/fields/types/relationship/views', getAdminMeta: (): Parameters<typeof import('./views').controller>[0]['fieldMeta'] => { const adminMetaRoot = getAdminMetaForRelationshipField(); - if (!meta.lists[foreignListKey]) { - throw new Error( - `The ref [${ref}] on relationship [${meta.listKey}.${meta.fieldKey}] is invalid` - ); + const localListMeta = adminMetaRoot.listsByKey[listKey]; + const foreignListMeta = adminMetaRoot.listsByKey[foreignListKey]; + + if (!foreignListMeta) { + throw new Error(`The ref [${ref}] on relationship [${listKey}.${fieldKey}] is invalid`); } + if (config.ui?.displayMode === 'cards') { // we're checking whether the field which will be in the admin meta at the time that getAdminMeta is called. // in newer versions of keystone, it will be there and it will not be there for older versions of keystone. // this is so that relationship fields doesn't break in confusing ways // if people are using a slightly older version of keystone - const currentField = adminMetaRoot.listsByKey[meta.listKey].fields.find( - x => x.path === meta.fieldKey - ); + const currentField = localListMeta.fields.find(x => x.key === fieldKey); if (currentField) { - const allForeignFields = new Set( - adminMetaRoot.listsByKey[foreignListKey].fields.map(x => x.path) - ); + const allForeignFields = new Set(foreignListMeta.fields.map(x => x.key)); for (const [configOption, foreignFields] of [ ['ui.cardFields', config.ui.cardFields], ['ui.inlineCreate.fields', config.ui.inlineCreate?.fields ?? []], @@ -108,7 +124,7 @@ export const relationship = for (const foreignField of foreignFields) { if (!allForeignFields.has(foreignField)) { throw new Error( - `The ${configOption} option on the relationship field at ${meta.listKey}.${meta.fieldKey} includes the "${foreignField}" field but that field does not exist on the "${foreignListKey}" list` + `The ${configOption} option on the relationship field at ${listKey}.${fieldKey} includes the "${foreignField}" field but that field does not exist on the "${foreignListKey}" list` ); } } @@ -116,38 +132,86 @@ export const relationship = } } + const hideCreate = config.ui?.hideCreate ?? false; + const refLabelField: typeof foreignFieldKey = foreignListMeta.labelField; + const refSearchFields: typeof foreignFieldKey[] = foreignListMeta.fields + .filter(x => x.search) + .map(x => x.key); + + if (config.ui?.displayMode === 'select') { + return { + refFieldKey: foreignFieldKey, + refListKey: foreignListKey, + many, + hideCreate, + displayMode: 'select', + + // prefer the local definition to the foreign list, if provided + refLabelField: config.ui.labelField || refLabelField, + refSearchFields: config.ui.searchFields || refSearchFields, + }; + } + + if (config.ui?.displayMode === 'cards') { + return { + refFieldKey: foreignFieldKey, + refListKey: foreignListKey, + many, + hideCreate, + displayMode: 'cards', + cardFields: config.ui.cardFields, + linkToItem: config.ui.linkToItem ?? false, + removeMode: config.ui.removeMode ?? 'disconnect', + inlineCreate: config.ui.inlineCreate ?? null, + inlineEdit: config.ui.inlineEdit ?? null, + inlineConnect: config.ui.inlineConnect ? true : false, + + // prefer the local definition to the foreign list, if provided + ...(typeof config.ui.inlineConnect === 'object' + ? { + refLabelField: config.ui.inlineConnect.labelField ?? refLabelField, + refSearchFields: config.ui.inlineConnect?.searchFields ?? refSearchFields, + } + : { + refLabelField, + refSearchFields, + }), + }; + } + + if (!(refLabelField in foreignListMeta.fieldsByKey)) { + throw new Error( + `The ui.labelField option for field '${fieldKey}' uses '${refLabelField}' but that field doesn't exist.` + ); + } + + for (const searchFieldKey of refSearchFields) { + if (!(searchFieldKey in foreignListMeta.fieldsByKey)) { + throw new Error( + `The ui.searchFields option for relationship field '${fieldKey}' includes '${searchFieldKey}' but that field doesn't exist.` + ); + } + + const field = foreignListMeta.fieldsByKey[searchFieldKey]; + if (field.search) continue; + + throw new Error( + `The ui.searchFields option for field '${fieldKey}' includes '${searchFieldKey}' but that field doesn't have a contains filter that accepts a GraphQL String` + ); + } + return { refFieldKey: foreignFieldKey, refListKey: foreignListKey, many, - hideCreate: config.ui?.hideCreate ?? false, - ...(config.ui?.displayMode === 'cards' - ? { - displayMode: 'cards', - cardFields: config.ui.cardFields, - linkToItem: config.ui.linkToItem ?? false, - removeMode: config.ui.removeMode ?? 'disconnect', - inlineCreate: config.ui.inlineCreate ?? null, - inlineEdit: config.ui.inlineEdit ?? null, - inlineConnect: config.ui.inlineConnect ?? false, - refLabelField: adminMetaRoot.listsByKey[foreignListKey].labelField, - } - : config.ui?.displayMode === 'count' - ? { displayMode: 'count' } - : { - displayMode: 'select', - refLabelField: - config.ui?.labelField || adminMetaRoot.listsByKey[foreignListKey].labelField, - }), + hideCreate, + displayMode: 'count', + refLabelField, + refSearchFields, }; }, }; - if (!meta.lists[foreignListKey]) { - throw new Error( - `Unable to resolve related list '${foreignListKey}' from ${meta.listKey}.${meta.fieldKey}` - ); - } - const listTypes = meta.lists[foreignListKey].types; + if (config.many) { return fieldType({ kind: 'relation', @@ -159,36 +223,39 @@ export const relationship = ...commonConfig, input: { where: { - arg: graphql.arg({ type: listTypes.relateTo.many.where }), + arg: graphql.arg({ type: foreignListTypes.relateTo.many.where }), resolve(value, context, resolve) { return resolve(value); }, }, - create: listTypes.relateTo.many.create && { - arg: graphql.arg({ type: listTypes.relateTo.many.create }), + create: foreignListTypes.relateTo.many.create && { + arg: graphql.arg({ type: foreignListTypes.relateTo.many.create }), async resolve(value, context, resolve) { return resolve(value); }, }, - update: listTypes.relateTo.many.update && { - arg: graphql.arg({ type: listTypes.relateTo.many.update }), + update: foreignListTypes.relateTo.many.update && { + arg: graphql.arg({ type: foreignListTypes.relateTo.many.update }), async resolve(value, context, resolve) { return resolve(value); }, }, }, output: graphql.field({ - args: listTypes.findManyArgs, - type: graphql.list(graphql.nonNull(listTypes.output)), + args: foreignListTypes.findManyArgs, + type: graphql.list(graphql.nonNull(foreignListTypes.output)), resolve({ value }, args) { return value.findMany(args); }, }), extraOutputFields: { - [`${meta.fieldKey}Count`]: graphql.field({ + [`${fieldKey}Count`]: graphql.field({ type: graphql.Int, args: { - where: graphql.arg({ type: graphql.nonNull(listTypes.where), defaultValue: {} }), + where: graphql.arg({ + type: graphql.nonNull(foreignListTypes.where), + defaultValue: {}, + }), }, resolve({ value }, args) { return value.count({ @@ -199,6 +266,7 @@ export const relationship = }, }); } + return fieldType({ kind: 'relation', mode: 'one', @@ -209,27 +277,27 @@ export const relationship = ...commonConfig, input: { where: { - arg: graphql.arg({ type: listTypes.where }), + arg: graphql.arg({ type: foreignListTypes.where }), resolve(value, context, resolve) { return resolve(value); }, }, - create: listTypes.relateTo.one.create && { - arg: graphql.arg({ type: listTypes.relateTo.one.create }), + create: foreignListTypes.relateTo.one.create && { + arg: graphql.arg({ type: foreignListTypes.relateTo.one.create }), async resolve(value, context, resolve) { return resolve(value); }, }, - update: listTypes.relateTo.one.update && { - arg: graphql.arg({ type: listTypes.relateTo.one.update }), + update: foreignListTypes.relateTo.one.update && { + arg: graphql.arg({ type: foreignListTypes.relateTo.one.update }), async resolve(value, context, resolve) { return resolve(value); }, }, }, output: graphql.field({ - type: listTypes.output, + type: foreignListTypes.output, resolve({ value }) { return value(); }, diff --git a/packages/core/src/fields/types/relationship/tests/implementation.test.ts b/packages/core/src/fields/types/relationship/tests/implementation.test.ts index cc8109ef515..7de146588d3 100644 --- a/packages/core/src/fields/types/relationship/tests/implementation.test.ts +++ b/packages/core/src/fields/types/relationship/tests/implementation.test.ts @@ -135,7 +135,7 @@ describe('Type Generation', () => { describe('Referenced list errors', () => { test('throws when list not found', async () => { expect(() => getSchema(relationship({ ref: 'DoesNotExist' }))).toThrow( - "Unable to resolve related list 'DoesNotExist' from Test.foo" + "Unable to resolve list 'DoesNotExist' for field Test.foo" ); }); diff --git a/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx b/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx index 28273b572c0..74340c4706e 100644 --- a/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx +++ b/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx @@ -56,7 +56,7 @@ function useDebouncedValue<T>(value: T, limitMs: number): T { return debouncedValue; } -export function useFilter(search: string, list: ListMeta) { +export function useFilter(search: string, list: ListMeta, searchFields: string[]) { return useMemo(() => { if (!search.length) return { OR: [] }; @@ -69,9 +69,8 @@ export function useFilter(search: string, list: ListMeta) { conditions.push({ id: { equals: trimmedSearch } }); } - for (const field of Object.values(list.fields)) { - if (field.search === null) continue; // in ui.searchFields - + for (const fieldKey of searchFields) { + const field = list.fields[fieldKey]; conditions.push({ [field.path]: { contains: trimmedSearch, @@ -81,12 +80,12 @@ export function useFilter(search: string, list: ListMeta) { } return { OR: conditions }; - }, [search, list]); + }, [search, list, searchFields]); } -const idField = '____id____'; +const idFieldAlias = '____id____'; -const labelField = '____label____'; +const labelFieldAlias = '____label____'; const LoadingIndicatorContext = createContext<{ count: number; @@ -101,6 +100,8 @@ export const RelationshipSelect = ({ controlShouldRenderValue, isDisabled, isLoading, + labelField, + searchFields, list, placeholder, portalMenu, @@ -111,6 +112,8 @@ export const RelationshipSelect = ({ controlShouldRenderValue: boolean; isDisabled: boolean; isLoading?: boolean; + labelField: string; + searchFields: string[]; list: ListMeta; placeholder?: string; portalMenu?: true | undefined; @@ -135,13 +138,13 @@ export const RelationshipSelect = ({ const [loadingIndicatorElement, setLoadingIndicatorElement] = useState<null | HTMLElement>(null); const QUERY: TypedDocumentNode< - { items: { [idField]: string; [labelField]: string | null }[]; count: number }, + { items: { [idFieldAlias]: string; [labelFieldAlias]: string | null }[]; count: number }, { where: Record<string, any>; take: number; skip: number } > = gql` query RelationshipSelect($where: ${list.gqlNames.whereInputName}!, $take: Int!, $skip: Int!) { items: ${list.gqlNames.listQueryName}(where: $where, take: $take, skip: $skip) { - ${idField}: id - ${labelField}: ${list.labelField} + ${idFieldAlias}: id + ${labelFieldAlias}: ${labelField} ${extraSelection} } count: ${list.gqlNames.listQueryCountName}(where: $where) @@ -149,7 +152,7 @@ export const RelationshipSelect = ({ `; const debouncedSearch = useDebouncedValue(search, 200); - const where = useFilter(debouncedSearch, list); + const where = useFilter(debouncedSearch, list, searchFields); const link = useApolloClient().link; // we're using a local apollo client here because writing a global implementation of the typePolicies @@ -192,7 +195,7 @@ export const RelationshipSelect = ({ const count = data?.count || 0; const options = - data?.items?.map(({ [idField]: value, [labelField]: label, ...data }) => ({ + data?.items?.map(({ [idFieldAlias]: value, [labelFieldAlias]: label, ...data }) => ({ value, label: label || value, data, @@ -229,13 +232,13 @@ export const RelationshipSelect = ({ lastFetchMore?.skip !== skip) ) { const QUERY: TypedDocumentNode< - { items: { [idField]: string; [labelField]: string | null }[] }, + { items: { [idFieldAlias]: string; [labelFieldAlias]: string | null }[] }, { where: Record<string, any>; take: number; skip: number } > = gql` query RelationshipSelectMore($where: ${list.gqlNames.whereInputName}!, $take: Int!, $skip: Int!) { items: ${list.gqlNames.listQueryName}(where: $where, take: $take, skip: $skip) { - ${labelField}: ${list.labelField} - ${idField}: id + ${labelFieldAlias}: ${labelField} + ${idFieldAlias}: id ${extraSelection} } } diff --git a/packages/core/src/fields/types/relationship/views/cards/index.tsx b/packages/core/src/fields/types/relationship/views/cards/index.tsx index be0711f7309..1adb7addc14 100644 --- a/packages/core/src/fields/types/relationship/views/cards/index.tsx +++ b/packages/core/src/fields/types/relationship/views/cards/index.tsx @@ -295,6 +295,8 @@ export function Cards({ controlShouldRenderValue={isLoadingLazyItems} isDisabled={onChange === undefined} list={foreignList} + labelField={field.refLabelField} + searchFields={field.refSearchFields} isLoading={isLoadingLazyItems} placeholder={`Select a ${foreignList.singular}`} portalMenu diff --git a/packages/core/src/fields/types/relationship/views/index.tsx b/packages/core/src/fields/types/relationship/views/index.tsx index 75840e26e21..d440303cb7e 100644 --- a/packages/core/src/fields/types/relationship/views/index.tsx +++ b/packages/core/src/fields/types/relationship/views/index.tsx @@ -131,6 +131,8 @@ export const Field = ({ aria-describedby={field.description === null ? undefined : `${field.path}-description`} autoFocus={autoFocus} isDisabled={onChange === undefined} + labelField={field.refLabelField} + searchFields={field.refSearchFields} list={foreignList} portalMenu state={ @@ -346,6 +348,8 @@ type RelationshipController = FieldController< listKey: string; refListKey: string; refFieldKey?: string; + refLabelField: string; + refSearchFields: string[]; hideCreate: boolean; many: boolean; }; @@ -357,10 +361,11 @@ export const controller = ( refListKey: string; many: boolean; hideCreate: boolean; + refLabelField: string; + refSearchFields: string[]; } & ( | { displayMode: 'select'; - refLabelField: string; } | { displayMode: 'cards'; @@ -370,9 +375,10 @@ export const controller = ( inlineCreate: { fields: readonly string[] } | null; inlineEdit: { fields: readonly string[] } | null; inlineConnect: boolean; - refLabelField: string; } - | { displayMode: 'count' } + | { + displayMode: 'count'; + } ) > ): RelationshipController => { @@ -388,6 +394,9 @@ export const controller = ( } : undefined; + const refLabelField = config.fieldMeta.refLabelField; + const refSearchFields = config.fieldMeta.refSearchFields; + return { refFieldKey: config.fieldMeta.refFieldKey, many: config.fieldMeta.many, @@ -396,13 +405,15 @@ export const controller = ( label: config.label, description: config.description, display: config.fieldMeta.displayMode === 'count' ? 'count' : 'cards-or-select', + refLabelField, + refSearchFields, refListKey: config.fieldMeta.refListKey, graphqlSelection: config.fieldMeta.displayMode === 'count' ? `${config.path}Count` : `${config.path} { id - label: ${config.fieldMeta.refLabelField} + label: ${refLabelField} }`, hideCreate: config.fieldMeta.hideCreate, // note we're not making the state kind: 'count' when ui.displayMode is set to 'count'. @@ -499,6 +510,8 @@ export const controller = ( <RelationshipSelect controlShouldRenderValue list={foreignList} + labelField={refLabelField} + searchFields={refSearchFields} isLoading={loading} isDisabled={onChange === undefined} state={state} @@ -630,7 +643,7 @@ function useRelationshipFilterValues({ value, list }: { value: string; list: Lis const query = gql` query FOREIGNLIST_QUERY($where: ${list.gqlNames.whereInputName}!) { items: ${list.gqlNames.listQueryName}(where: $where) { - id + id ${list.labelField} } } diff --git a/packages/core/src/lib/core/types-for-lists.ts b/packages/core/src/lib/core/types-for-lists.ts index 0acdbe065c8..c4946003524 100644 --- a/packages/core/src/lib/core/types-for-lists.ts +++ b/packages/core/src/lib/core/types-for-lists.ts @@ -1,7 +1,9 @@ import { CacheHint } from 'apollo-server-types'; +import { GraphQLString, isInputObjectType } from 'graphql'; import { BaseItem, GraphQLTypesForList, + QueryMode, getGqlNames, NextFieldType, BaseListTypeInfo, @@ -53,6 +55,11 @@ export type InitialisedList = { adminUILabels: { label: string; singular: string; plural: string; path: string }; cacheHint: ((args: CacheHintArgs) => CacheHint) | undefined; listKey: string; + ui: { + labelField: string; + searchFields: Set<string>; + searchableFields: Map<string, 'default' | 'insensitive' | null>; + }; lists: Record<string, InitialisedList>; dbMap: string | undefined; graphql: { @@ -185,6 +192,22 @@ function getListsWithInitialisedFields( }; } + // Default the labelField to `name`, `label`, or `title` if they exist; otherwise fall back to `id` + const labelField = + list.ui?.labelField ?? + (list.fields.label + ? 'label' + : list.fields.name + ? 'name' + : list.fields.title + ? 'title' + : 'id'); + + const searchFields = new Set(list.ui?.searchFields ?? []); + if (searchFields.has('id')) { + throw new Error(`${listKey}.ui.searchFields cannot include 'id'`); + } + result[listKey] = { fields: resultFields, ...intermediateList, @@ -193,6 +216,11 @@ function getListsWithInitialisedFields( dbMap: list.db?.map, types: listGraphqlTypes[listKey].types, + ui: { + labelField, + searchFields, + searchableFields: new Map<string, 'default' | 'insensitive' | null>(), + }, hooks: list.hooks || {}, listKey, @@ -210,6 +238,38 @@ function getListsWithInitialisedFields( return result; } +function introspectGraphQLTypes(lists: Record<string, InitialisedList>) { + for (const [listKey, list] of Object.entries(lists)) { + const { + ui: { searchFields, searchableFields }, + } = list; + + if (searchFields.has('id')) { + throw new Error( + `The ui.searchFields option on the ${listKey} list includes 'id'. Lists can always be searched by an item's id so it must not be specified as a search field` + ); + } + + const whereInputFields = list.types.where.graphQLType.getFields(); + for (const fieldKey of Object.keys(list.fields)) { + const filterType = whereInputFields[fieldKey]?.type; + const fieldFilterFields = isInputObjectType(filterType) ? filterType.getFields() : undefined; + if (fieldFilterFields?.contains?.type === GraphQLString) { + searchableFields.set( + fieldKey, + fieldFilterFields?.mode?.type === QueryMode.graphQLType ? 'insensitive' : 'default' + ); + } + } + + if (searchFields.size === 0) { + if (searchableFields.has(list.ui.labelField)) { + searchFields.add(list.ui.labelField); + } + } + } +} + function getListGraphqlTypes( listsConfig: KeystoneConfig['lists'], lists: Record<string, InitialisedList>, @@ -481,7 +541,7 @@ export function initialiseLists(config: KeystoneConfig): Record<string, Initiali /** * Lists is instantiated here so that it can be passed into the `getListGraphqlTypes` function - * This function attaches this list object to the various graphql functions + * This function binds the listsRef object to the various graphql functions * * The object will be populated at the end of this function, and the reference will be maintained */ @@ -495,9 +555,12 @@ export function initialiseLists(config: KeystoneConfig): Record<string, Initiali { const resolvedDBFieldsForLists = resolveRelationships(intermediateLists); intermediateLists = Object.fromEntries( - Object.entries(intermediateLists).map(([listKey, blah]) => [ + Object.entries(intermediateLists).map(([listKey, list]) => [ listKey, - { ...blah, resolvedDbFields: resolvedDBFieldsForLists[listKey] }, + { + ...list, + resolvedDbFields: resolvedDBFieldsForLists[listKey], + }, ]) ); } @@ -539,13 +602,12 @@ export function initialiseLists(config: KeystoneConfig): Record<string, Initiali } } - /* - Error checking - */ + // Error checking for (const [listKey, { fields }] of Object.entries(intermediateLists)) { assertFieldsValid({ listKey, fields }); } + // Fixup the GraphQL refs for (const [listKey, intermediateList] of Object.entries(intermediateLists)) { listsRef[listKey] = { ...intermediateList, @@ -553,5 +615,8 @@ export function initialiseLists(config: KeystoneConfig): Record<string, Initiali }; } + // Do some introspection + introspectGraphQLTypes(listsRef); + return listsRef; } diff --git a/packages/core/src/types/config/lists.ts b/packages/core/src/types/config/lists.ts index 13d48bc824c..0cbbbb3bf2a 100644 --- a/packages/core/src/types/config/lists.ts +++ b/packages/core/src/types/config/lists.ts @@ -63,7 +63,7 @@ export type ListAdminUIConfig< * The field to use as a label in the Admin UI. If you want to base the label off more than a single field, use a virtual field and reference that field here. * @default 'label', if it exists, falling back to 'name', then 'title', and finally 'id', which is guaranteed to exist. */ - labelField?: 'id' | keyof Fields; + labelField?: 'id' | Exclude<keyof Fields, number>; /** * The fields used by the Admin UI when searching this list. * It is always possible to search by id and `id` should not be specified in this option. diff --git a/packages/fields-document/package.json b/packages/fields-document/package.json index fd986e5be33..3b2f19e357d 100644 --- a/packages/fields-document/package.json +++ b/packages/fields-document/package.json @@ -39,7 +39,7 @@ ] }, "peerDependencies": { - "@keystone-6/core": "^3.0.0" + "@keystone-6/core": "^3.1.0" }, "dependencies": { "@babel/runtime": "^7.16.3", diff --git a/packages/fields-document/src/DocumentEditor/component-blocks/form-from-preview.tsx b/packages/fields-document/src/DocumentEditor/component-blocks/form-from-preview.tsx index 10d8355d407..33dddc903e5 100644 --- a/packages/fields-document/src/DocumentEditor/component-blocks/form-from-preview.tsx +++ b/packages/fields-document/src/DocumentEditor/component-blocks/form-from-preview.tsx @@ -54,6 +54,9 @@ function RelationshipFieldPreview({ value, }: DefaultFieldProps<'relationship'>) { const keystone = useKeystone(); + const list = keystone.adminMeta.lists[schema.listKey]; + const searchFields = Object.keys(list.fields).filter(key => list.fields[key].search); + return ( <FieldContainer> <FieldLabel>{schema.label}</FieldLabel> @@ -61,7 +64,9 @@ function RelationshipFieldPreview({ autoFocus={autoFocus} controlShouldRenderValue isDisabled={false} - list={keystone.adminMeta.lists[schema.listKey]} + list={list} + labelField={list.labelField} + searchFields={searchFields} extraSelection={schema.selection || ''} portalMenu state={ diff --git a/packages/fields-document/src/DocumentEditor/relationship.tsx b/packages/fields-document/src/DocumentEditor/relationship.tsx index 2a8b77c4a36..ea2bce3f666 100644 --- a/packages/fields-document/src/DocumentEditor/relationship.tsx +++ b/packages/fields-document/src/DocumentEditor/relationship.tsx @@ -83,6 +83,9 @@ export function RelationshipElement({ const editor = useStaticEditor(); const relationships = useContext(DocumentFieldRelationshipsContext)!; const relationship = relationships[element.relationship]; + const list = keystone.adminMeta.lists[relationship.listKey]; + const searchFields = Object.keys(list.fields).filter(key => list.fields[key].search); + return ( <span {...attributes} @@ -106,7 +109,9 @@ export function RelationshipElement({ <RelationshipSelect controlShouldRenderValue isDisabled={false} - list={keystone.adminMeta.lists[relationship.listKey]} + list={list} + labelField={list.labelField} + searchFields={searchFields} portalMenu state={{ kind: 'one', diff --git a/tests/sandbox/configs/all-the-things.ts b/tests/sandbox/configs/all-the-things.ts index 7c09bea3620..9f3eb49b284 100644 --- a/tests/sandbox/configs/all-the-things.ts +++ b/tests/sandbox/configs/all-the-things.ts @@ -31,10 +31,15 @@ export const lists = { checkbox: checkbox({ ui: { description } }), password: password({ ui: { description } }), toOneRelationship: relationship({ + ref: 'User', + ui: { description }, + }), + toOneRelationshipAlternateLabel: relationship({ ref: 'User', ui: { description, labelField: 'email', + searchFields: ['email', 'name'], }, }), toManyRelationship: relationship({ ref: 'Todo', many: true, ui: { description } }), @@ -44,7 +49,9 @@ export const lists = { description, displayMode: 'cards', cardFields: ['name', 'email'], - inlineConnect: true, + inlineConnect: { + labelField: 'email', + }, inlineCreate: { fields: ['name', 'email'] }, linkToItem: true, inlineEdit: { fields: ['name', 'email'] }, diff --git a/tests/sandbox/schema.graphql b/tests/sandbox/schema.graphql index a51c8530c46..b35a2b15e5b 100644 --- a/tests/sandbox/schema.graphql +++ b/tests/sandbox/schema.graphql @@ -6,6 +6,7 @@ type Thing { checkbox: Boolean password: PasswordState toOneRelationship: User + toOneRelationshipAlternateLabel: User toManyRelationship(where: TodoWhereInput! = {}, orderBy: [TodoOrderByInput!]! = [], take: Int, skip: Int! = 0): [Todo!] toManyRelationshipCount(where: TodoWhereInput! = {}): Int toOneRelationshipCard: User @@ -77,6 +78,7 @@ input ThingWhereInput { checkbox: BooleanFilter password: PasswordFilter toOneRelationship: UserWhereInput + toOneRelationshipAlternateLabel: UserWhereInput toManyRelationship: TodoManyRelationFilter toOneRelationshipCard: UserWhereInput toManyRelationshipCard: TodoManyRelationFilter @@ -253,6 +255,7 @@ input ThingUpdateInput { checkbox: Boolean password: String toOneRelationship: UserRelateToOneForUpdateInput + toOneRelationshipAlternateLabel: UserRelateToOneForUpdateInput toManyRelationship: TodoRelateToManyForUpdateInput toOneRelationshipCard: UserRelateToOneForUpdateInput toManyRelationshipCard: TodoRelateToManyForUpdateInput @@ -306,6 +309,7 @@ input ThingCreateInput { checkbox: Boolean password: String toOneRelationship: UserRelateToOneForCreateInput + toOneRelationshipAlternateLabel: UserRelateToOneForCreateInput toManyRelationship: TodoRelateToManyForCreateInput toOneRelationshipCard: UserRelateToOneForCreateInput toManyRelationshipCard: TodoRelateToManyForCreateInput diff --git a/tests/sandbox/schema.prisma b/tests/sandbox/schema.prisma index 8b5e72fe06c..59f4288553e 100644 --- a/tests/sandbox/schema.prisma +++ b/tests/sandbox/schema.prisma @@ -13,37 +13,40 @@ generator client { } model Thing { - id String @id @default(cuid()) - checkbox Boolean @default(false) - password String? - toOneRelationship User? @relation("Thing_toOneRelationship", fields: [toOneRelationshipId], references: [id]) - toOneRelationshipId String? @map("toOneRelationship") - toManyRelationship Todo[] @relation("Thing_toManyRelationship") - toOneRelationshipCard User? @relation("Thing_toOneRelationshipCard", fields: [toOneRelationshipCardId], references: [id]) - toOneRelationshipCardId String? @map("toOneRelationshipCard") - toManyRelationshipCard Todo[] @relation("Thing_toManyRelationshipCard") - text String @default("") - timestamp DateTime? - calendarDay String? - select String? - selectOnSide String? - selectOnSideItemViewOnly String? - selectSegmentedControl String? - multiselect String @default("[]") - json String? - integer Int? - bigInt BigInt? - float Float? - image_filesize Int? - image_extension String? - image_width Int? - image_height Int? - image_id String? - file_filesize Int? - file_filename String? - document String @default("[{\"type\":\"paragraph\",\"children\":[{\"text\":\"\"}]}]") + id String @id @default(cuid()) + checkbox Boolean @default(false) + password String? + toOneRelationship User? @relation("Thing_toOneRelationship", fields: [toOneRelationshipId], references: [id]) + toOneRelationshipId String? @map("toOneRelationship") + toOneRelationshipAlternateLabel User? @relation("Thing_toOneRelationshipAlternateLabel", fields: [toOneRelationshipAlternateLabelId], references: [id]) + toOneRelationshipAlternateLabelId String? @map("toOneRelationshipAlternateLabel") + toManyRelationship Todo[] @relation("Thing_toManyRelationship") + toOneRelationshipCard User? @relation("Thing_toOneRelationshipCard", fields: [toOneRelationshipCardId], references: [id]) + toOneRelationshipCardId String? @map("toOneRelationshipCard") + toManyRelationshipCard Todo[] @relation("Thing_toManyRelationshipCard") + text String @default("") + timestamp DateTime? + calendarDay String? + select String? + selectOnSide String? + selectOnSideItemViewOnly String? + selectSegmentedControl String? + multiselect String @default("[]") + json String? + integer Int? + bigInt BigInt? + float Float? + image_filesize Int? + image_extension String? + image_width Int? + image_height Int? + image_id String? + file_filesize Int? + file_filename String? + document String @default("[{\"type\":\"paragraph\",\"children\":[{\"text\":\"\"}]}]") @@index([toOneRelationshipId]) + @@index([toOneRelationshipAlternateLabelId]) @@index([toOneRelationshipCardId]) } @@ -63,15 +66,16 @@ model Todo { } model User { - id String @id @default(cuid()) - name String @default("") - email String @default("") - password String? - tasks Todo[] @relation("Todo_assignedTo") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - from_Thing_toOneRelationship Thing[] @relation("Thing_toOneRelationship") - from_Thing_toOneRelationshipCard Thing[] @relation("Thing_toOneRelationshipCard") + id String @id @default(cuid()) + name String @default("") + email String @default("") + password String? + tasks Todo[] @relation("Todo_assignedTo") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + from_Thing_toOneRelationship Thing[] @relation("Thing_toOneRelationship") + from_Thing_toOneRelationshipAlternateLabel Thing[] @relation("Thing_toOneRelationshipAlternateLabel") + from_Thing_toOneRelationshipCard Thing[] @relation("Thing_toOneRelationshipCard") } model Settings {