Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add labelField, searchFields support for relationship field #8074

Merged
merged 12 commits into from
Nov 15, 2022
Merged
1 change: 1 addition & 0 deletions .changeset/thirty-apples-cry.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
'@keystone-6/core': patch
'@keystone-6/fields-document': patch
---

Fix relationship fields not using their `ui.labelField` configuration
5 changes: 5 additions & 0 deletions .changeset/thirty-strawberries-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystone-6/core': minor
---

Adds `ui.searchFields` for the relationship field
8 changes: 8 additions & 0 deletions docs/pages/docs/fields/relationship.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,21 @@ 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.
- `removeMode` (default: `'disconnect'`): Controls whether the `Remove` button is present in the card. If `'disconnect'`, the button will be present. If `'none'`, the button will not be present.
- `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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'> = {};
Expand All @@ -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;
Expand Down Expand Up @@ -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 />
Expand Down
101 changes: 37 additions & 64 deletions packages/core/src/admin-ui/system/createAdminMeta.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import path from 'path';
import { GraphQLString, isInputObjectType } from 'graphql';
import {
KeystoneConfig,
QueryMode,
MaybePromise,
MaybeSessionFunction,
BaseListTypeInfo,
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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>);
Expand All @@ -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) {
Expand All @@ -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);
}
Expand All @@ -116,23 +109,25 @@ 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:
(listConfig.ui?.listView?.initialSort as
| { 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
Expand All @@ -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> = {};
Expand All @@ -156,65 +151,37 @@ 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.
if (field.dbField.kind === 'relation' && omittedLists.includes(field.dbField.list)) continue;
// 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'
Expand All @@ -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;
}
}

Expand Down
Loading