From 9546bb2023ed7cad7883ae687ef7847cd8240707 Mon Sep 17 00:00:00 2001 From: pnodet Date: Fri, 15 Dec 2023 23:46:56 +0700 Subject: [PATCH 01/10] feat: add search by relations fields --- .../admin-ui/pages/ListPage/index.tsx | 27 +++++++++++---- .../relationship/views/RelationshipSelect.tsx | 34 +++++++++++++++++-- 2 files changed, 53 insertions(+), 8 deletions(-) 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 ed92bc8ef77..714028cc0b5 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 @@ -26,7 +26,7 @@ import { Pagination, PaginationLabel, usePaginationParams } from '../../../../ad import { useList } from '../../../../admin-ui/context' import { GraphQLErrorNotice } from '../../../../admin-ui/components/GraphQLErrorNotice' import { Link, useRouter } from '../../../../admin-ui/router' -import { useFilter } from '../../../../fields/types/relationship/views/RelationshipSelect' +import { RelationsSearchFields, useFilter } from '../../../../fields/types/relationship/views/RelationshipSelect' import { CreateButtonLink } from '../../../../admin-ui/components/CreateButtonLink' import { FieldSelection } from './FieldSelection' import { FilterAdd } from './FilterAdd' @@ -84,7 +84,6 @@ const storeableQueries = ['sortBy', 'fields'] function useQueryParamsFromLocalStorage (listKey: string) { const router = useRouter() const localStorageKey = `keystone.list.${listKey}.list.page.info` - const resetToDefaults = () => { localStorage.removeItem(localStorageKey) router.replace({ pathname: router.pathname }) @@ -136,8 +135,8 @@ function ListPage ({ listKey }: ListPageProps) { const { query, push } = useRouter() const { resetToDefaults } = useQueryParamsFromLocalStorage(listKey) const { currentPage, pageSize } = usePaginationParams({ defaultPageSize: list.pageSize }) - const metaQuery = useQuery(listMetaGraphqlQuery, { variables: { listKey } }) + const metaQuery = useQuery(listMetaGraphqlQuery, { variables: { listKey } }) const { listViewFieldModesByField, filterableFields, orderableFields } = useMemo(() => { const listViewFieldModesByField: Record = {} const orderableFields = new Set() @@ -157,13 +156,29 @@ function ListPage ({ listKey }: ListPageProps) { const sort = useSort(list, orderableFields) const filters = useFilters(list, filterableFields) - const searchFields = Object.keys(list.fields).filter(key => list.fields[key].search) - const searchLabels = searchFields.map(key => list.fields[key].label) + const relationsSearchFields: RelationsSearchFields[] = Object.keys(list.fields) + .map((key) => { + const field = list.fields[key] + + // @ts-expect-error Wrong types for relationship fields + if (!field.fieldMeta.many !== undefined) return + + return { + field: key, + // @ts-expect-error Wrong types for relationship fields + refSearchFields: field.fieldMeta?.refSearchFields, + // @ts-expect-error Wrong types for relationship fields + many: field.fieldMeta?.many, + } + }) + .filter(Boolean) as RelationsSearchFields[] + + 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, searchFields) + const search = useFilter(searchParam, list, searchFields, relationsSearchFields) const updateSearch = (value: string) => { const { search, ...queries } = query diff --git a/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx b/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx index 7729fb9f18f..ac191b29e04 100644 --- a/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx +++ b/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx @@ -72,14 +72,19 @@ function isUuid (x: unknown) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(x) } -export function useFilter (value: string, list: ListMeta, searchFields: string[]) { +export type RelationsSearchFields = { + field: string + refSearchFields: string[] + many: boolean +} + +export function useFilter (value: string, list: ListMeta, searchFields: string[], relationsSearchFields: RelationsSearchFields[] = []) { return useMemo(() => { const trimmedSearch = value.trim() if (!trimmedSearch.length) return { OR: [] } const conditions: Record[] = [] const idField = list.fields.id.fieldMeta as { type: string, kind: string } - console.error({ idField, value, meta: list.fields.id.fieldMeta }) if (idField.type === 'String') { // TODO: remove in breaking change? @@ -107,6 +112,31 @@ export function useFilter (value: string, list: ListMeta, searchFields: string[] }) } + for (const { field, refSearchFields, many } of relationsSearchFields) { + conditions.push( + ...refSearchFields.map((refSearchField) => + many + ? { + [field]: { + some: { + [refSearchField]: { + contains: trimmedSearch, + }, + }, + }, + } + : { + [field]: { + [refSearchField]: { + contains: trimmedSearch, + }, + }, + }, + ), + ) + } + + return { OR: conditions } }, [value, list, searchFields]) } From 3440214edf65bc0e9c9a22ae4d11a6f3863c8c9e Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 3 Sep 2024 12:23:03 +1000 Subject: [PATCH 02/10] add requirement for searchField ui.contains filter to docs/ --- docs/content/docs/config/lists.md | 2 +- docs/content/docs/fields/relationship.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/config/lists.md b/docs/content/docs/config/lists.md index 9140322e5f0..0c6318d81da 100644 --- a/docs/content/docs/config/lists.md +++ b/docs/content/docs/config/lists.md @@ -77,7 +77,7 @@ Options: - `labelField`: Selects the field which will be used as the label column in the Admin UI. By default looks for a field called `'label'`, then falls back to `'name'`, then `'title'`, and finally `'id'`, which is guaranteed to exist. -- `searchFields`: The fields used by the Admin UI when searching this list on the list view and in relationship fields. +- `searchFields`: The fields used by the Admin UI when searching this list on the list view and in relationship fields. Nominated fields need to support the `contains` filter. It is always possible to search by an id and `'id'` should not be specified in this option. By default, the `labelField` is used if it has a string `contains` filter, otherwise none. - `description` (default: `undefined`): Sets the list description displayed in the Admin UI. diff --git a/docs/content/docs/fields/relationship.md b/docs/content/docs/fields/relationship.md index fd85a4a58b9..33824fe2370 100644 --- a/docs/content/docs/fields/relationship.md +++ b/docs/content/docs/fields/relationship.md @@ -16,7 +16,7 @@ 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. -- `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. +- `searchFields`: The fields used by the Admin UI when searching by this relationship on the list view and in relationship fields. Nominated fields need to support the `contains` filter. - `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. From 6af1096b480949b28d204f86e16cb108ede2d1bd Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 3 Sep 2024 12:24:55 +1000 Subject: [PATCH 03/10] tidy up ui fields in docs/ --- docs/content/docs/config/lists.md | 6 +++--- docs/content/docs/fields/relationship.md | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/content/docs/config/lists.md b/docs/content/docs/config/lists.md index 0c6318d81da..eebfed74b33 100644 --- a/docs/content/docs/config/lists.md +++ b/docs/content/docs/config/lists.md @@ -89,17 +89,17 @@ Options: Can be either a boolean value or an async function with an argument `{ session, context }` that returns a boolean value. - `createView`: Controls the create view page of the Admin UI. - `defaultFieldMode` (default: `'edit'`): - Can be overridden by per-field values in the `field.ui.createView.fieldMode` config. + Can be overridden by per-field values in the `ui.createView.fieldMode` config. See the [Fields API](../fields/overview#common-configuration) for details. Can be one of `['edit', 'hidden']`, or an async function with an argument `{ session, context }` that returns one of `['edit', 'hidden']`. - `itemView`: Controls the item view page of the Admin UI. - `defaultFieldMode` (default: `'edit'`): - Can be overridden by per-field values in the `field.ui.itemView.fieldMode` config. + Can be overridden by per-field values in the `ui.itemView.fieldMode` config. See the [Fields API](../fields/overview#common-configuration) for details. Can be one of `['edit', 'read', 'hidden']`, or an async function with an argument `{ session, context, item }` that returns one of `['edit', 'read', 'hidden']`. - `listView`: Controls the list view page of the Admin UI. - `defaultFieldMode` (default: `'read'`): Controls the default mode of fields in the list view. - Can be overridden by per-field values in the `field.ui.listView.fieldMode` config. + Can be overridden by per-field values in the `ui.listView.fieldMode` config. See the [Fields API](../fields/overview#common-configuration) for details. Can be one of `['read', 'hidden']`, or an async function with an argument `{ session, context }` that returns one of `['read', 'hidden']`. - `initialColumns` (default: The first three fields defined in the list). A list of field names to display in columns in the list view. By default only the label column, as determined by `labelField`, is shown. diff --git a/docs/content/docs/fields/relationship.md b/docs/content/docs/fields/relationship.md index 33824fe2370..16416f3a9fe 100644 --- a/docs/content/docs/fields/relationship.md +++ b/docs/content/docs/fields/relationship.md @@ -14,10 +14,10 @@ Read our [relationships guide](../guides/relationships) for details on Keystone - `ui` (default: `{ hideCreate: false, displayMode: 'select' }`): Configures the display mode of the field in the Admin UI. - `hideCreate` (default: `false`). If `true`, the "Create related item" button is not shown in the item view. - `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: +- `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. -- `searchFields`: The fields used by the Admin UI when searching by this relationship on the list view and in relationship fields. Nominated fields need to support the `contains` filter. -- `ui.displayMode === 'cards'` options: +- `searchFields`: The fields used by the Admin UI when searching by this relationship on the list view and in relationship fields. Nominated fields need to support the `contains` filter. +- `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. @@ -27,7 +27,7 @@ Read our [relationships guide](../guides/relationships) for details on Keystone 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. -- `ui.displayMode === 'count'` only supports `many` relationships +- `displayMode === 'count'` only supports `many` relationships ```typescript import { config, list } from '@keystone-6/core'; From b786cab0fbddf3ccdec255ff7da4bcadec93f995 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 3 Sep 2024 12:33:18 +1000 Subject: [PATCH 04/10] fix mode for search fields, and implicitly require that searchFields asks for that relationship --- .../admin-ui/pages/ListPage/index.tsx | 21 +----- .../relationship/views/RelationshipSelect.tsx | 67 ++++++++++--------- 2 files changed, 37 insertions(+), 51 deletions(-) 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 714028cc0b5..8aa8882fc79 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 @@ -26,7 +26,7 @@ import { Pagination, PaginationLabel, usePaginationParams } from '../../../../ad import { useList } from '../../../../admin-ui/context' import { GraphQLErrorNotice } from '../../../../admin-ui/components/GraphQLErrorNotice' import { Link, useRouter } from '../../../../admin-ui/router' -import { RelationsSearchFields, useFilter } from '../../../../fields/types/relationship/views/RelationshipSelect' +import { useFilter } from '../../../../fields/types/relationship/views/RelationshipSelect' import { CreateButtonLink } from '../../../../admin-ui/components/CreateButtonLink' import { FieldSelection } from './FieldSelection' import { FilterAdd } from './FilterAdd' @@ -158,27 +158,10 @@ function ListPage ({ listKey }: ListPageProps) { const filters = useFilters(list, filterableFields) const searchFields = Object.keys(list.fields).filter(key => list.fields[key].search) - const relationsSearchFields: RelationsSearchFields[] = Object.keys(list.fields) - .map((key) => { - const field = list.fields[key] - - // @ts-expect-error Wrong types for relationship fields - if (!field.fieldMeta.many !== undefined) return - - return { - field: key, - // @ts-expect-error Wrong types for relationship fields - refSearchFields: field.fieldMeta?.refSearchFields, - // @ts-expect-error Wrong types for relationship fields - many: field.fieldMeta?.many, - } - }) - .filter(Boolean) as RelationsSearchFields[] - 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, searchFields, relationsSearchFields) + const search = useFilter(searchParam, list, searchFields) const updateSearch = (value: string) => { const { search, ...queries } = query diff --git a/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx b/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx index ac191b29e04..a3f4c4b5c87 100644 --- a/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx +++ b/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx @@ -72,13 +72,7 @@ function isUuid (x: unknown) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(x) } -export type RelationsSearchFields = { - field: string - refSearchFields: string[] - many: boolean -} - -export function useFilter (value: string, list: ListMeta, searchFields: string[], relationsSearchFields: RelationsSearchFields[] = []) { +export function useFilter (value: string, list: ListMeta, searchFields: string[]) { return useMemo(() => { const trimmedSearch = value.trim() if (!trimmedSearch.length) return { OR: [] } @@ -104,6 +98,40 @@ export function useFilter (value: string, list: ListMeta, searchFields: string[] for (const fieldKey of searchFields) { const field = list.fields[fieldKey] + + // @ts-expect-error TODO: fix fieldMeta type for relationship fields + if (field.fieldMeta?.refSearchFields) { + // @ts-expect-error TODO: fix fieldMeta type for relationship fields + for (const fieldKey of field.fieldMeta?.refSearchFields) { + const field = list.fields[fieldKey] + + // @ts-expect-error TODO: fix fieldMeta type for relationship fields + if (field.fieldMeta?.many) { + conditions.push({ + [fieldKey]: { + some: { + [fieldKey]: { + contains: trimmedSearch, + mode: field.search === 'insensitive' ? 'insensitive' : undefined, + }, + }, + }, + }) + + continue + } + + conditions.push({ + [field.path]: { + contains: trimmedSearch, + mode: field.search === 'insensitive' ? 'insensitive' : undefined, + }, + }) + } + + continue + } + conditions.push({ [field.path]: { contains: trimmedSearch, @@ -112,31 +140,6 @@ export function useFilter (value: string, list: ListMeta, searchFields: string[] }) } - for (const { field, refSearchFields, many } of relationsSearchFields) { - conditions.push( - ...refSearchFields.map((refSearchField) => - many - ? { - [field]: { - some: { - [refSearchField]: { - contains: trimmedSearch, - }, - }, - }, - } - : { - [field]: { - [refSearchField]: { - contains: trimmedSearch, - }, - }, - }, - ), - ) - } - - return { OR: conditions } }, [value, list, searchFields]) } From 6394dce76a3fb9afac88a6d8335c7a1c97206209 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 3 Sep 2024 12:46:10 +1000 Subject: [PATCH 05/10] add TODO for throwing if searchFields configuration is invalid --- packages/core/src/lib/core/initialise-lists.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/lib/core/initialise-lists.ts b/packages/core/src/lib/core/initialise-lists.ts index 1e0b7fc2043..5bb774b5edc 100644 --- a/packages/core/src/lib/core/initialise-lists.ts +++ b/packages/core/src/lib/core/initialise-lists.ts @@ -827,6 +827,8 @@ function introspectGraphQLTypes (lists: Record) { fieldKey, fieldFilterFields?.mode?.type === QueryMode.graphQLType ? 'insensitive' : 'default' ) + } else { + // TODO: throw? } } From 2318799d3813ca58bb6f395fd24d627bf80fce21 Mon Sep 17 00:00:00 2001 From: Daniel Cousens Date: Mon, 18 Nov 2024 16:41:56 +1100 Subject: [PATCH 06/10] add changesets, fix broken search --- .changeset/add-relationship-search.md | 5 + .changeset/fix-search.md | 5 + examples/better-list-search/README.md | 32 ++ examples/better-list-search/keystone.ts | 14 + examples/better-list-search/package.json | 23 ++ .../better-list-search/sandbox.config.json | 7 + examples/better-list-search/schema.graphql | 328 ++++++++++++++++++ examples/better-list-search/schema.prisma | 25 ++ examples/better-list-search/schema.ts | 42 +++ examples/usecase-blog/schema.ts | 2 + .../admin-ui/pages/ListPage/index.tsx | 25 +- .../core/src/admin-ui/admin-meta-graphql.ts | 2 + .../src/admin-ui/components/Navigation.tsx | 2 +- packages/core/src/admin-ui/context.tsx | 14 +- .../src/fields/types/relationship/index.ts | 13 +- .../relationship/views/RelationshipSelect.tsx | 42 ++- packages/core/src/lib/create-admin-meta.ts | 7 + packages/core/src/lib/resolve-admin-meta.ts | 16 +- packages/core/src/types/admin-meta.ts | 1 + pnpm-lock.yaml | 22 ++ 20 files changed, 586 insertions(+), 41 deletions(-) create mode 100644 .changeset/add-relationship-search.md create mode 100644 .changeset/fix-search.md create mode 100644 examples/better-list-search/README.md create mode 100644 examples/better-list-search/keystone.ts create mode 100644 examples/better-list-search/package.json create mode 100644 examples/better-list-search/sandbox.config.json create mode 100644 examples/better-list-search/schema.graphql create mode 100644 examples/better-list-search/schema.prisma create mode 100644 examples/better-list-search/schema.ts diff --git a/.changeset/add-relationship-search.md b/.changeset/add-relationship-search.md new file mode 100644 index 00000000000..7d037994509 --- /dev/null +++ b/.changeset/add-relationship-search.md @@ -0,0 +1,5 @@ +--- +"@keystone-6/core": minor +--- + +Add support for searching relationship fields in the list view diff --git a/.changeset/fix-search.md b/.changeset/fix-search.md new file mode 100644 index 00000000000..508774fc8e0 --- /dev/null +++ b/.changeset/fix-search.md @@ -0,0 +1,5 @@ +--- +"@keystone-6/core": patch +--- + +Fix AdminUI list view ignoring `.ui.searchFields` when searching diff --git a/examples/better-list-search/README.md b/examples/better-list-search/README.md new file mode 100644 index 00000000000..479accb2e51 --- /dev/null +++ b/examples/better-list-search/README.md @@ -0,0 +1,32 @@ +## Base Project - Blog + +This project implements a basic **Blog**, with `Posts` and `Authors`. + +Use it as a starting place for learning how to use Keystone. + +## Instructions + +To run this project, clone the Keystone repository locally, run `pnpm install` at the root of this repository, then navigate to this directory and run: + +```shell +pnpm dev +``` + +This will start the Admin UI at [localhost:3000](http://localhost:3000). +You can use the Admin UI to create items in your database. + +You can also access a GraphQL Playground at [localhost:3000/api/graphql](http://localhost:3000/api/graphql), which allows you to directly run GraphQL queries and mutations. + +Congratulations, you're now up and running with Keystone! 🚀 + +### Optional: add sample data + +This example includes sample data. To add it to your database: + +1. Ensure you’ve initialised your project with `pnpm dev` at least once. +2. Run `pnpm seed-data`. This will populate your database with sample content. +3. Run `pnpm dev` again to startup Admin UI with sample data in place. + +## Try it out in CodeSandbox 🧪 + +You can play with this example online in a web browser using the free [codesandbox.io](https://codesandbox.io/) service. To launch this example, open the URL . You can also fork this sandbox to make your own changes. diff --git a/examples/better-list-search/keystone.ts b/examples/better-list-search/keystone.ts new file mode 100644 index 00000000000..e76d454cffe --- /dev/null +++ b/examples/better-list-search/keystone.ts @@ -0,0 +1,14 @@ +import { config } from '@keystone-6/core' +import { lists } from './schema' +import type { TypeInfo } from '.keystone/types' + +export default config({ + db: { + provider: 'sqlite', + url: process.env.DATABASE_URL || 'file:./keystone-example.db', + + // WARNING: this is only needed for our monorepo examples, dont do this + prismaClientPath: 'node_modules/myprisma', + }, + lists, +}) diff --git a/examples/better-list-search/package.json b/examples/better-list-search/package.json new file mode 100644 index 00000000000..83b3b1cad39 --- /dev/null +++ b/examples/better-list-search/package.json @@ -0,0 +1,23 @@ +{ + "name": "@keystone-6/example-better-list-search", + "version": null, + "private": true, + "license": "MIT", + "scripts": { + "dev": "keystone dev", + "start": "keystone start", + "build": "keystone build", + "postinstall": "keystone postinstall", + "seed-data": "tsx seed-data.ts" + }, + "dependencies": { + "@keystone-6/core": "^6.3.1", + "@keystone-6/fields-document": "^9.1.1", + "@prisma/client": "5.19.0" + }, + "devDependencies": { + "prisma": "5.19.0", + "tsx": "^4.0.0", + "typescript": "^5.5.0" + } +} diff --git a/examples/better-list-search/sandbox.config.json b/examples/better-list-search/sandbox.config.json new file mode 100644 index 00000000000..c5d3215212f --- /dev/null +++ b/examples/better-list-search/sandbox.config.json @@ -0,0 +1,7 @@ +{ + "template": "node", + "container": { + "startScript": "keystone dev", + "node": "20" + } +} diff --git a/examples/better-list-search/schema.graphql b/examples/better-list-search/schema.graphql new file mode 100644 index 00000000000..34abbbf2cd3 --- /dev/null +++ b/examples/better-list-search/schema.graphql @@ -0,0 +1,328 @@ +# This file is automatically generated by Keystone, do not modify it manually. +# Modify your Keystone config when you want to change this. + +type Post { + id: ID! + title: String + tags(where: TagWhereInput! = {}, orderBy: [TagOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: TagWhereUniqueInput): [Tag!] + tagsCount(where: TagWhereInput! = {}): Int +} + +input PostWhereUniqueInput { + id: ID +} + +input PostWhereInput { + AND: [PostWhereInput!] + OR: [PostWhereInput!] + NOT: [PostWhereInput!] + id: IDFilter + title: StringFilter + tags: TagManyRelationFilter +} + +input IDFilter { + equals: ID + in: [ID!] + notIn: [ID!] + lt: ID + lte: ID + gt: ID + gte: ID + not: IDFilter +} + +input StringFilter { + equals: String + in: [String!] + notIn: [String!] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + not: NestedStringFilter +} + +input NestedStringFilter { + equals: String + in: [String!] + notIn: [String!] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + not: NestedStringFilter +} + +input TagManyRelationFilter { + every: TagWhereInput + some: TagWhereInput + none: TagWhereInput +} + +input PostOrderByInput { + id: OrderDirection + title: OrderDirection +} + +enum OrderDirection { + asc + desc +} + +input PostUpdateInput { + title: String + tags: TagRelateToManyForUpdateInput +} + +input TagRelateToManyForUpdateInput { + disconnect: [TagWhereUniqueInput!] + set: [TagWhereUniqueInput!] + create: [TagCreateInput!] + connect: [TagWhereUniqueInput!] +} + +input PostUpdateArgs { + where: PostWhereUniqueInput! + data: PostUpdateInput! +} + +input PostCreateInput { + title: String + tags: TagRelateToManyForCreateInput +} + +input TagRelateToManyForCreateInput { + create: [TagCreateInput!] + connect: [TagWhereUniqueInput!] +} + +type Tag { + id: ID! + name: String + posts(where: PostWhereInput! = {}, orderBy: [PostOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: PostWhereUniqueInput): [Post!] + postsCount(where: PostWhereInput! = {}): Int +} + +input TagWhereUniqueInput { + id: ID +} + +input TagWhereInput { + AND: [TagWhereInput!] + OR: [TagWhereInput!] + NOT: [TagWhereInput!] + id: IDFilter + name: StringFilter + posts: PostManyRelationFilter +} + +input PostManyRelationFilter { + every: PostWhereInput + some: PostWhereInput + none: PostWhereInput +} + +input TagOrderByInput { + id: OrderDirection + name: OrderDirection +} + +input TagUpdateInput { + name: String + posts: PostRelateToManyForUpdateInput +} + +input PostRelateToManyForUpdateInput { + disconnect: [PostWhereUniqueInput!] + set: [PostWhereUniqueInput!] + create: [PostCreateInput!] + connect: [PostWhereUniqueInput!] +} + +input TagUpdateArgs { + where: TagWhereUniqueInput! + data: TagUpdateInput! +} + +input TagCreateInput { + name: String + posts: PostRelateToManyForCreateInput +} + +input PostRelateToManyForCreateInput { + create: [PostCreateInput!] + connect: [PostWhereUniqueInput!] +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +type Mutation { + createPost(data: PostCreateInput!): Post + createPosts(data: [PostCreateInput!]!): [Post] + updatePost(where: PostWhereUniqueInput!, data: PostUpdateInput!): Post + updatePosts(data: [PostUpdateArgs!]!): [Post] + deletePost(where: PostWhereUniqueInput!): Post + deletePosts(where: [PostWhereUniqueInput!]!): [Post] + createTag(data: TagCreateInput!): Tag + createTags(data: [TagCreateInput!]!): [Tag] + updateTag(where: TagWhereUniqueInput!, data: TagUpdateInput!): Tag + updateTags(data: [TagUpdateArgs!]!): [Tag] + deleteTag(where: TagWhereUniqueInput!): Tag + deleteTags(where: [TagWhereUniqueInput!]!): [Tag] +} + +type Query { + post(where: PostWhereUniqueInput!): Post + posts(where: PostWhereInput! = {}, orderBy: [PostOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: PostWhereUniqueInput): [Post!] + postsCount(where: PostWhereInput! = {}): Int + tag(where: TagWhereUniqueInput!): Tag + tags(where: TagWhereInput! = {}, orderBy: [TagOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: TagWhereUniqueInput): [Tag!] + tagsCount(where: TagWhereInput! = {}): Int + keystone: KeystoneMeta! +} + +type KeystoneMeta { + adminMeta: KeystoneAdminMeta! +} + +type KeystoneAdminMeta { + lists: [KeystoneAdminUIListMeta!]! + list(key: String!): KeystoneAdminUIListMeta +} + +type KeystoneAdminUIListMeta { + key: String! + path: String! + label: String! + singular: String! + plural: String! + description: String + initialColumns: [String!]! + initialSearch: [String!]! + pageSize: Int! + labelField: String! + fields: [KeystoneAdminUIFieldMeta!]! + groups: [KeystoneAdminUIFieldGroupMeta!]! + graphql: KeystoneAdminUIGraphQL! + initialSort: KeystoneAdminUISort + isSingleton: Boolean! + hideCreate: Boolean! + hideDelete: Boolean! + isHidden: Boolean! + itemQueryName: String! + listQueryName: String! +} + +type KeystoneAdminUIFieldMeta { + path: String! + label: String! + description: String + isOrderable: Boolean! + isFilterable: Boolean! + isNonNull: [KeystoneAdminUIFieldMetaIsNonNull!] + fieldMeta: JSON + viewsIndex: Int! + customViewsIndex: Int + createView: KeystoneAdminUIFieldMetaCreateView! + listView: KeystoneAdminUIFieldMetaListView! + itemView(id: ID): KeystoneAdminUIFieldMetaItemView + search: QueryMode +} + +enum KeystoneAdminUIFieldMetaIsNonNull { + read + create + update +} + +type KeystoneAdminUIFieldMetaCreateView { + fieldMode: KeystoneAdminUIFieldMetaCreateViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaCreateViewFieldMode { + edit + hidden +} + +type KeystoneAdminUIFieldMetaListView { + fieldMode: KeystoneAdminUIFieldMetaListViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaListViewFieldMode { + read + hidden +} + +type KeystoneAdminUIFieldMetaItemView { + fieldMode: KeystoneAdminUIFieldMetaItemViewFieldMode + fieldPosition: KeystoneAdminUIFieldMetaItemViewFieldPosition +} + +enum KeystoneAdminUIFieldMetaItemViewFieldMode { + edit + read + hidden +} + +enum KeystoneAdminUIFieldMetaItemViewFieldPosition { + form + sidebar +} + +enum QueryMode { + default + insensitive +} + +type KeystoneAdminUIFieldGroupMeta { + label: String! + description: String + fields: [KeystoneAdminUIFieldMeta!]! +} + +type KeystoneAdminUIGraphQL { + names: KeystoneAdminUIGraphQLNames! +} + +type KeystoneAdminUIGraphQLNames { + outputTypeName: String! + whereInputName: String! + whereUniqueInputName: String! + createInputName: String! + createMutationName: String! + createManyMutationName: String! + relateToOneForCreateInputName: String! + relateToManyForCreateInputName: String! + itemQueryName: String! + listOrderName: String! + listQueryCountName: String! + listQueryName: String! + updateInputName: String! + updateMutationName: String! + updateManyInputName: String! + updateManyMutationName: String! + relateToOneForUpdateInputName: String! + relateToManyForUpdateInputName: String! + deleteMutationName: String! + deleteManyMutationName: String! +} + +type KeystoneAdminUISort { + field: String! + direction: KeystoneAdminUISortDirection! +} + +enum KeystoneAdminUISortDirection { + ASC + DESC +} diff --git a/examples/better-list-search/schema.prisma b/examples/better-list-search/schema.prisma new file mode 100644 index 00000000000..b8b5c13a5fb --- /dev/null +++ b/examples/better-list-search/schema.prisma @@ -0,0 +1,25 @@ +// This file is automatically generated by Keystone, do not modify it manually. +// Modify your Keystone config when you want to change this. + +datasource sqlite { + url = env("DATABASE_URL") + shadowDatabaseUrl = env("SHADOW_DATABASE_URL") + provider = "sqlite" +} + +generator client { + provider = "prisma-client-js" + output = "node_modules/myprisma" +} + +model Post { + id String @id @default(cuid()) + title String @default("") + tags Tag[] @relation("Post_tags") +} + +model Tag { + id String @id @default(cuid()) + name String @default("") + posts Post[] @relation("Post_tags") +} diff --git a/examples/better-list-search/schema.ts b/examples/better-list-search/schema.ts new file mode 100644 index 00000000000..e73bf709c82 --- /dev/null +++ b/examples/better-list-search/schema.ts @@ -0,0 +1,42 @@ +import { list } from '@keystone-6/core' +import { allowAll } from '@keystone-6/core/access' + +import { text, relationship } from '@keystone-6/core/fields' +import type { Lists } from '.keystone/types' + +export const lists = { + Post: list({ + // WARNING - for this example, anyone can create, query, update and delete anything + access: allowAll, + + fields: { + title: text({ validation: { isRequired: true } }), + tags: relationship({ + ref: 'Tag.posts', + many: true, + }), + }, + + ui: { + searchFields: [ + 'tags', + ] + } + }), + + Tag: list({ + // WARNING - for this example, anyone can create, query, update and delete anything + access: allowAll, + + fields: { + name: text({ validation: { isRequired: true } }), + posts: relationship({ ref: 'Post.tags', many: true }), + }, + + ui: { + searchFields: [ + 'name', + ] + } + }), +} satisfies Lists diff --git a/examples/usecase-blog/schema.ts b/examples/usecase-blog/schema.ts index 0f278ce9127..211d7c010a9 100644 --- a/examples/usecase-blog/schema.ts +++ b/examples/usecase-blog/schema.ts @@ -80,6 +80,8 @@ export const lists = { inlineEdit: { fields: ['name', 'email'] }, linkToItem: true, inlineConnect: true, + + searchFields: [], }, many: false, // only 1 author for each Post (the default) 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 8aa8882fc79..62412e58afe 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 @@ -23,10 +23,13 @@ import { gql, type TypedDocumentNode, useMutation, useQuery } from '../../../../ import { CellLink } from '../../../../admin-ui/components' import { PageContainer, HEADER_HEIGHT } from '../../../../admin-ui/components/PageContainer' import { Pagination, PaginationLabel, usePaginationParams } from '../../../../admin-ui/components/Pagination' -import { useList } from '../../../../admin-ui/context' +import { + useKeystone, + useList +} from '../../../../admin-ui/context' import { GraphQLErrorNotice } from '../../../../admin-ui/components/GraphQLErrorNotice' import { Link, useRouter } from '../../../../admin-ui/router' -import { useFilter } from '../../../../fields/types/relationship/views/RelationshipSelect' +import { useSearchFilter } from '../../../../fields/types/relationship/views/RelationshipSelect' import { CreateButtonLink } from '../../../../admin-ui/components/CreateButtonLink' import { FieldSelection } from './FieldSelection' import { FilterAdd } from './FilterAdd' @@ -130,8 +133,8 @@ function useQueryParamsFromLocalStorage (listKey: string) { export const getListPage = (props: ListPageProps) => () => function ListPage ({ listKey }: ListPageProps) { + const keystone = useKeystone() const list = useList(listKey) - const { query, push } = useRouter() const { resetToDefaults } = useQueryParamsFromLocalStorage(listKey) const { currentPage, pageSize } = usePaginationParams({ defaultPageSize: list.pageSize }) @@ -156,12 +159,12 @@ function ListPage ({ listKey }: ListPageProps) { const sort = useSort(list, orderableFields) const filters = useFilters(list, filterableFields) - const searchFields = Object.keys(list.fields).filter(key => list.fields[key].search) - const searchLabels = searchFields.map(key => list.fields[key].label) + const searchLabels = list.initialSearch.map(key => list.fields[key].label) const searchParam = typeof query.search === 'string' ? query.search : '' const [searchString, updateSearchString] = useState(searchParam) - const search = useFilter(searchParam, list, searchFields) + const search = useSearchFilter(searchParam, list, list.initialSearch, keystone.adminMeta.lists) + const updateSearch = (value: string) => { const { search, ...queries } = query @@ -364,7 +367,7 @@ function ListPage ({ listKey }: ListPageProps) { ) } -const ListPageHeader = ({ listKey }: { listKey: string }) => { +function ListPageHeader ({ listKey }: { listKey: string }) { const list = useList(listKey) return ( @@ -382,8 +385,8 @@ const ListPageHeader = ({ listKey }: { listKey: string }) => { ) } -const ResultsSummaryContainer = ({ children }: { children: ReactNode }) => ( -

( > {children}

-) +} -const SortDirectionArrow = ({ direction }: { direction: 'ASC' | 'DESC' }) => { +function SortDirectionArrow ({ direction }: { direction: 'ASC' | 'DESC' }) { const size = '0.25em' return ( + initialSearch: Array initialSort: { __typename: 'KeystoneAdminUISort' field: string diff --git a/packages/core/src/admin-ui/components/Navigation.tsx b/packages/core/src/admin-ui/components/Navigation.tsx index 2baecafed1a..5104ee2edc1 100644 --- a/packages/core/src/admin-ui/components/Navigation.tsx +++ b/packages/core/src/admin-ui/components/Navigation.tsx @@ -196,7 +196,7 @@ export const ListNavItems = ({ lists = [], include = [] }: NavItemsProps) => { ) } -export const Navigation = () => { +export function Navigation () { const { adminMeta: { lists }, adminConfig, diff --git a/packages/core/src/admin-ui/context.tsx b/packages/core/src/admin-ui/context.tsx index e634ca48997..f5c69af3de6 100644 --- a/packages/core/src/admin-ui/context.tsx +++ b/packages/core/src/admin-ui/context.tsx @@ -147,10 +147,18 @@ export function useRawKeystone () { throw new Error('useRawKeystone must be called inside a KeystoneProvider component') } -export function useList (key: string) { +export function useList (listKey: string) { const { adminMeta: { lists }, } = useKeystone() - if (key in lists) return lists[key] - throw new Error(`Invalid list key provided to useList: ${key}`) + const list = lists[listKey] + if (!list) throw new Error(`Unknown field ${listKey}`) + return list +} + +export function useField (listKey: string, fieldKey: string) { + const list = useList(listKey) + const field = list.fields[fieldKey] + if (!field) throw new Error(`Unknown field ${listKey}.${fieldKey}`) + return field } diff --git a/packages/core/src/fields/types/relationship/index.ts b/packages/core/src/fields/types/relationship/index.ts index b37b6a388ac..cce8164d6e5 100644 --- a/packages/core/src/fields/types/relationship/index.ts +++ b/packages/core/src/fields/types/relationship/index.ts @@ -88,19 +88,17 @@ export type RelationshipFieldConfig = } & (OneDbConfig | ManyDbConfig) & (SelectDisplayConfig | CardsDisplayConfig | CountDisplayConfig) -export const relationship = - ({ - ref, - ...config - }: RelationshipFieldConfig): FieldTypeFunc => - ({ fieldKey, listKey, lists }) => { +export function relationship ({ + ref, + ...config +}: RelationshipFieldConfig): FieldTypeFunc { + return ({ fieldKey, listKey, lists }) => { const { many = false } = config const [foreignListKey, foreignFieldKey] = ref.split('.') const foreignList = lists[foreignListKey] if (!foreignList) throw new Error(`${listKey}.${fieldKey} points to ${ref}, but ${ref} doesn't exist`) const foreignListTypes = foreignList.types - const commonConfig = { ...config, __ksTelemetryFieldTypeName: '@keystone-6/relationship', @@ -338,3 +336,4 @@ export const relationship = }), }) } +} diff --git a/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx b/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx index a3f4c4b5c87..f8fdec6bdea 100644 --- a/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx +++ b/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx @@ -72,7 +72,9 @@ function isUuid (x: unknown) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(x) } -export function useFilter (value: string, list: ListMeta, searchFields: string[]) { +export function useSearchFilter (value: string, list: ListMeta, searchFields: string[], lists: { + [list: string]: ListMeta +}) { return useMemo(() => { const trimmedSearch = value.trim() if (!trimmedSearch.length) return { OR: [] } @@ -101,18 +103,26 @@ export function useFilter (value: string, list: ListMeta, searchFields: string[] // @ts-expect-error TODO: fix fieldMeta type for relationship fields if (field.fieldMeta?.refSearchFields) { - // @ts-expect-error TODO: fix fieldMeta type for relationship fields - for (const fieldKey of field.fieldMeta?.refSearchFields) { - const field = list.fields[fieldKey] - + const { + // @ts-expect-error TODO: fix fieldMeta type for relationship fields + refListKey, // @ts-expect-error TODO: fix fieldMeta type for relationship fields - if (field.fieldMeta?.many) { + refSearchFields, + // @ts-expect-error TODO: fix fieldMeta type for relationship fields + many = false, + } = field.fieldMeta + const refList = lists[refListKey] + + for (const refFieldKey of refSearchFields) { + const refField = refList.fields[refFieldKey] + + if (many) { conditions.push({ [fieldKey]: { some: { - [fieldKey]: { + [refFieldKey]: { contains: trimmedSearch, - mode: field.search === 'insensitive' ? 'insensitive' : undefined, + mode: refField.search === 'insensitive' ? 'insensitive' : undefined, }, }, }, @@ -122,9 +132,13 @@ export function useFilter (value: string, list: ListMeta, searchFields: string[] } conditions.push({ - [field.path]: { - contains: trimmedSearch, - mode: field.search === 'insensitive' ? 'insensitive' : undefined, + [fieldKey]: { + some: { + [refFieldKey]: { + contains: trimmedSearch, + mode: refField.search === 'insensitive' ? 'insensitive' : undefined, + }, + }, }, }) } @@ -155,7 +169,7 @@ const LoadingIndicatorContext = createContext<{ ref: () => {}, }) -export const RelationshipSelect = ({ +export function RelationshipSelect ({ autoFocus, controlShouldRenderValue, isDisabled, @@ -189,7 +203,7 @@ export const RelationshipSelect = ({ onChange(value: { label: string, id: string, data: Record } | null): void } extraSelection?: string -}) => { +}) { const [search, setSearch] = useState('') // note it's important that this is in state rather than a ref // because we want a re-render if the element changes @@ -212,7 +226,7 @@ export const RelationshipSelect = ({ ` const debouncedSearch = useDebouncedValue(search, 200) - const where = useFilter(debouncedSearch, list, searchFields) + const where = useSearchFilter(debouncedSearch, list, searchFields) const link = useApolloClient().link // we're using a local apollo client here because writing a global implementation of the typePolicies diff --git a/packages/core/src/lib/create-admin-meta.ts b/packages/core/src/lib/create-admin-meta.ts index 7785a90c537..19e07f503c9 100644 --- a/packages/core/src/lib/create-admin-meta.ts +++ b/packages/core/src/lib/create-admin-meta.ts @@ -66,6 +66,7 @@ export type ListMetaRootVal = { graphql: { names: GraphQLNames } pageSize: number initialColumns: string[] + initialSearch: string[] initialSort: { field: string, direction: 'ASC' | 'DESC' } | null isSingleton: boolean @@ -125,6 +126,11 @@ export function createAdminMeta ( ].slice(0, 3) } + let initialSearch = listConfig.ui?.searchFields + if (!initialSearch) { + initialSearch = [...list.ui.searchableFields.keys()] + } + const maximumPageSize = Math.min( listConfig.ui?.listView?.pageSize ?? 50, (list.graphql.types.findManyArgs.take.defaultValue ?? Infinity) as number @@ -149,6 +155,7 @@ export function createAdminMeta ( pageSize: maximumPageSize, initialColumns, + initialSearch, initialSort: (listConfig.ui?.listView?.initialSort as | { field: string, direction: 'ASC' | 'DESC' } diff --git a/packages/core/src/lib/resolve-admin-meta.ts b/packages/core/src/lib/resolve-admin-meta.ts index 02ce0fdc004..4a3f750eac1 100644 --- a/packages/core/src/lib/resolve-admin-meta.ts +++ b/packages/core/src/lib/resolve-admin-meta.ts @@ -223,10 +223,6 @@ const KeystoneAdminUIListMeta = graphql.object()({ name: 'KeystoneAdminUIListMeta', fields: { key: graphql.field({ type: graphql.nonNull(graphql.String) }), - itemQueryName: graphql.field({ type: graphql.nonNull(graphql.String) }), - listQueryName: graphql.field({ type: graphql.nonNull(graphql.String) }), - ...contextFunctionField('hideCreate', graphql.Boolean), - ...contextFunctionField('hideDelete', graphql.Boolean), path: graphql.field({ type: graphql.nonNull(graphql.String) }), label: graphql.field({ type: graphql.nonNull(graphql.String) }), singular: graphql.field({ type: graphql.nonNull(graphql.String) }), @@ -235,6 +231,9 @@ const KeystoneAdminUIListMeta = graphql.object()({ initialColumns: graphql.field({ type: graphql.nonNull(graphql.list(graphql.nonNull(graphql.String))), }), + initialSearch: graphql.field({ + type: graphql.nonNull(graphql.list(graphql.nonNull(graphql.String))), + }), pageSize: graphql.field({ type: graphql.nonNull(graphql.Int) }), labelField: graphql.field({ type: graphql.nonNull(graphql.String) }), fields: graphql.field({ @@ -245,8 +244,15 @@ const KeystoneAdminUIListMeta = graphql.object()({ }), graphql: graphql.field({ type: graphql.nonNull(KeystoneAdminUIGraphQL) }), initialSort: graphql.field({ type: KeystoneAdminUISort }), - ...contextFunctionField('isHidden', graphql.Boolean), isSingleton: graphql.field({ type: graphql.nonNull(graphql.Boolean) }), + + ...contextFunctionField('hideCreate', graphql.Boolean), + ...contextFunctionField('hideDelete', graphql.Boolean), + ...contextFunctionField('isHidden', graphql.Boolean), + + // TODO: remove in breaking change + itemQueryName: graphql.field({ type: graphql.nonNull(graphql.String) }), + listQueryName: graphql.field({ type: graphql.nonNull(graphql.String) }), }, }) diff --git a/packages/core/src/types/admin-meta.ts b/packages/core/src/types/admin-meta.ts index f225dbc19c6..24625c7c549 100644 --- a/packages/core/src/types/admin-meta.ts +++ b/packages/core/src/types/admin-meta.ts @@ -117,6 +117,7 @@ export type ListMeta = { pageSize: number initialColumns: string[] + initialSearch: string[] initialSort: null | { direction: 'ASC' | 'DESC', field: string } isSingleton: boolean } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eafacb8072f..4212be45d3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -663,6 +663,28 @@ importers: specifier: ^5.5.0 version: 5.6.3 + examples/better-list-search: + dependencies: + '@keystone-6/core': + specifier: ^6.3.1 + version: link:../../packages/core + '@keystone-6/fields-document': + specifier: ^9.1.1 + version: link:../../packages/fields-document + '@prisma/client': + specifier: 5.19.0 + version: 5.19.0(prisma@5.19.0) + devDependencies: + prisma: + specifier: 5.19.0 + version: 5.19.0 + tsx: + specifier: ^4.0.0 + version: 4.19.2 + typescript: + specifier: ^5.5.0 + version: 5.6.3 + examples/cloudinary: dependencies: '@keystone-6/auth': From e5b4431b966f907b682f6c27b4fcbbe0f48b751c Mon Sep 17 00:00:00 2001 From: Daniel Cousens Date: Mon, 18 Nov 2024 17:45:23 +1100 Subject: [PATCH 07/10] add singular search example --- examples/better-list-search/schema.graphql | 59 +++++++++++++++++++ examples/better-list-search/schema.prisma | 16 ++++- examples/better-list-search/schema.ts | 22 ++++++- .../relationship/views/RelationshipSelect.tsx | 8 +-- 4 files changed, 96 insertions(+), 9 deletions(-) diff --git a/examples/better-list-search/schema.graphql b/examples/better-list-search/schema.graphql index 34abbbf2cd3..2748f39ea7d 100644 --- a/examples/better-list-search/schema.graphql +++ b/examples/better-list-search/schema.graphql @@ -4,6 +4,7 @@ type Post { id: ID! title: String + author: Author tags(where: TagWhereInput! = {}, orderBy: [TagOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: TagWhereUniqueInput): [Tag!] tagsCount(where: TagWhereInput! = {}): Int } @@ -18,6 +19,7 @@ input PostWhereInput { NOT: [PostWhereInput!] id: IDFilter title: StringFilter + author: AuthorWhereInput tags: TagManyRelationFilter } @@ -78,9 +80,16 @@ enum OrderDirection { input PostUpdateInput { title: String + author: AuthorRelateToOneForUpdateInput tags: TagRelateToManyForUpdateInput } +input AuthorRelateToOneForUpdateInput { + create: AuthorCreateInput + connect: AuthorWhereUniqueInput + disconnect: Boolean +} + input TagRelateToManyForUpdateInput { disconnect: [TagWhereUniqueInput!] set: [TagWhereUniqueInput!] @@ -95,14 +104,55 @@ input PostUpdateArgs { input PostCreateInput { title: String + author: AuthorRelateToOneForCreateInput tags: TagRelateToManyForCreateInput } +input AuthorRelateToOneForCreateInput { + create: AuthorCreateInput + connect: AuthorWhereUniqueInput +} + input TagRelateToManyForCreateInput { create: [TagCreateInput!] connect: [TagWhereUniqueInput!] } +type Author { + id: ID! + name: String +} + +input AuthorWhereUniqueInput { + id: ID +} + +input AuthorWhereInput { + AND: [AuthorWhereInput!] + OR: [AuthorWhereInput!] + NOT: [AuthorWhereInput!] + id: IDFilter + name: StringFilter +} + +input AuthorOrderByInput { + id: OrderDirection + name: OrderDirection +} + +input AuthorUpdateInput { + name: String +} + +input AuthorUpdateArgs { + where: AuthorWhereUniqueInput! + data: AuthorUpdateInput! +} + +input AuthorCreateInput { + name: String +} + type Tag { id: ID! name: String @@ -173,6 +223,12 @@ type Mutation { updatePosts(data: [PostUpdateArgs!]!): [Post] deletePost(where: PostWhereUniqueInput!): Post deletePosts(where: [PostWhereUniqueInput!]!): [Post] + createAuthor(data: AuthorCreateInput!): Author + createAuthors(data: [AuthorCreateInput!]!): [Author] + updateAuthor(where: AuthorWhereUniqueInput!, data: AuthorUpdateInput!): Author + updateAuthors(data: [AuthorUpdateArgs!]!): [Author] + deleteAuthor(where: AuthorWhereUniqueInput!): Author + deleteAuthors(where: [AuthorWhereUniqueInput!]!): [Author] createTag(data: TagCreateInput!): Tag createTags(data: [TagCreateInput!]!): [Tag] updateTag(where: TagWhereUniqueInput!, data: TagUpdateInput!): Tag @@ -185,6 +241,9 @@ type Query { post(where: PostWhereUniqueInput!): Post posts(where: PostWhereInput! = {}, orderBy: [PostOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: PostWhereUniqueInput): [Post!] postsCount(where: PostWhereInput! = {}): Int + author(where: AuthorWhereUniqueInput!): Author + authors(where: AuthorWhereInput! = {}, orderBy: [AuthorOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: AuthorWhereUniqueInput): [Author!] + authorsCount(where: AuthorWhereInput! = {}): Int tag(where: TagWhereUniqueInput!): Tag tags(where: TagWhereInput! = {}, orderBy: [TagOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: TagWhereUniqueInput): [Tag!] tagsCount(where: TagWhereInput! = {}): Int diff --git a/examples/better-list-search/schema.prisma b/examples/better-list-search/schema.prisma index b8b5c13a5fb..85aa33f550b 100644 --- a/examples/better-list-search/schema.prisma +++ b/examples/better-list-search/schema.prisma @@ -13,9 +13,19 @@ generator client { } model Post { - id String @id @default(cuid()) - title String @default("") - tags Tag[] @relation("Post_tags") + id String @id @default(cuid()) + title String @default("") + author Author? @relation("Post_author", fields: [authorId], references: [id]) + authorId String? @map("author") + tags Tag[] @relation("Post_tags") + + @@index([authorId]) +} + +model Author { + id String @id @default(cuid()) + name String @default("") + from_Post_author Post[] @relation("Post_author") } model Tag { diff --git a/examples/better-list-search/schema.ts b/examples/better-list-search/schema.ts index e73bf709c82..bf45b8aaa39 100644 --- a/examples/better-list-search/schema.ts +++ b/examples/better-list-search/schema.ts @@ -11,6 +11,9 @@ export const lists = { fields: { title: text({ validation: { isRequired: true } }), + author: relationship({ + ref: 'Author', + }), tags: relationship({ ref: 'Tag.posts', many: true, @@ -19,7 +22,24 @@ export const lists = { ui: { searchFields: [ - 'tags', + 'author', + 'tags', // WARNING: results in searching by post.tags.name + // this is quite powerful, but may load your database + ] + } + }), + + Author: list({ + // WARNING - for this example, anyone can create, query, update and delete anything + access: allowAll, + + fields: { + name: text({ validation: { isRequired: true } }), + }, + + ui: { + searchFields: [ + 'name', ] } }), diff --git a/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx b/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx index f8fdec6bdea..2e57cec54a4 100644 --- a/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx +++ b/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx @@ -133,11 +133,9 @@ export function useSearchFilter (value: string, list: ListMeta, searchFields: st conditions.push({ [fieldKey]: { - some: { - [refFieldKey]: { - contains: trimmedSearch, - mode: refField.search === 'insensitive' ? 'insensitive' : undefined, - }, + [refFieldKey]: { + contains: trimmedSearch, + mode: refField.search === 'insensitive' ? 'insensitive' : undefined, }, }, }) From 42586f576ccedd1bde61ae979112e9d6c3c5c32c Mon Sep 17 00:00:00 2001 From: Daniel Cousens Date: Mon, 18 Nov 2024 18:37:19 +1100 Subject: [PATCH 08/10] fix other usage --- examples/usecase-blog/schema.ts | 2 -- .../fields/types/relationship/views/RelationshipSelect.tsx | 6 +++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/usecase-blog/schema.ts b/examples/usecase-blog/schema.ts index 211d7c010a9..0f278ce9127 100644 --- a/examples/usecase-blog/schema.ts +++ b/examples/usecase-blog/schema.ts @@ -80,8 +80,6 @@ export const lists = { inlineEdit: { fields: ['name', 'email'] }, linkToItem: true, inlineConnect: true, - - searchFields: [], }, many: false, // only 1 author for each Post (the default) diff --git a/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx b/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx index 2e57cec54a4..94507dc5ebc 100644 --- a/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx +++ b/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx @@ -23,6 +23,9 @@ import { useApolloClient, useQuery, } from '../../../../admin-ui/apollo' +import { + useKeystone +} from '../../../../admin-ui/context' function useIntersectionObserver (cb: IntersectionObserverCallback, ref: RefObject) { const cbRef = useRef(cb) @@ -202,6 +205,7 @@ export function RelationshipSelect ({ } extraSelection?: string }) { + const keystone = useKeystone() const [search, setSearch] = useState('') // note it's important that this is in state rather than a ref // because we want a re-render if the element changes @@ -224,7 +228,7 @@ export function RelationshipSelect ({ ` const debouncedSearch = useDebouncedValue(search, 200) - const where = useSearchFilter(debouncedSearch, list, searchFields) + const where = useSearchFilter(debouncedSearch, list, searchFields, keystone.adminMeta.lists) const link = useApolloClient().link // we're using a local apollo client here because writing a global implementation of the typePolicies From d7c2f704038c8ad574d27c24c64493ce94734ff4 Mon Sep 17 00:00:00 2001 From: Daniel Cousens Date: Tue, 19 Nov 2024 11:51:22 +1100 Subject: [PATCH 09/10] fix types --- packages/core/src/lib/create-admin-meta.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/lib/create-admin-meta.ts b/packages/core/src/lib/create-admin-meta.ts index 19e07f503c9..33beba51451 100644 --- a/packages/core/src/lib/create-admin-meta.ts +++ b/packages/core/src/lib/create-admin-meta.ts @@ -126,7 +126,7 @@ export function createAdminMeta ( ].slice(0, 3) } - let initialSearch = listConfig.ui?.searchFields + let initialSearch = listConfig.ui?.searchFields?.concat() if (!initialSearch) { initialSearch = [...list.ui.searchableFields.keys()] } From 96af6ad2b39f2203218cff86f0bb2d8f48528fc7 Mon Sep 17 00:00:00 2001 From: Daniel Cousens Date: Tue, 19 Nov 2024 12:50:50 +1100 Subject: [PATCH 10/10] update relationship field to respect searchFields --- design-system/packages/fields/src/Select.tsx | 1 + examples/better-list-search/schema.graphql | 41 +++++++++++-------- examples/better-list-search/schema.prisma | 12 +++--- examples/better-list-search/schema.ts | 4 ++ .../src/fields/types/relationship/index.ts | 19 +++------ .../relationship/views/RelationshipSelect.tsx | 28 ++++++++----- 6 files changed, 57 insertions(+), 48 deletions(-) diff --git a/design-system/packages/fields/src/Select.tsx b/design-system/packages/fields/src/Select.tsx index c643bfd7583..406ba3038bf 100644 --- a/design-system/packages/fields/src/Select.tsx +++ b/design-system/packages/fields/src/Select.tsx @@ -167,6 +167,7 @@ export function MultiSelect ({ inputId={id} styles={composedStyles} value={value} + filterOption={null} onChange={value => { if (!value) { onChange([]) diff --git a/examples/better-list-search/schema.graphql b/examples/better-list-search/schema.graphql index 2748f39ea7d..a883699fd0d 100644 --- a/examples/better-list-search/schema.graphql +++ b/examples/better-list-search/schema.graphql @@ -5,6 +5,8 @@ type Post { id: ID! title: String author: Author + related(where: PostWhereInput! = {}, orderBy: [PostOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: PostWhereUniqueInput): [Post!] + relatedCount(where: PostWhereInput! = {}): Int tags(where: TagWhereInput! = {}, orderBy: [TagOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: TagWhereUniqueInput): [Tag!] tagsCount(where: TagWhereInput! = {}): Int } @@ -20,6 +22,7 @@ input PostWhereInput { id: IDFilter title: StringFilter author: AuthorWhereInput + related: PostManyRelationFilter tags: TagManyRelationFilter } @@ -62,6 +65,12 @@ input NestedStringFilter { not: NestedStringFilter } +input PostManyRelationFilter { + every: PostWhereInput + some: PostWhereInput + none: PostWhereInput +} + input TagManyRelationFilter { every: TagWhereInput some: TagWhereInput @@ -81,6 +90,7 @@ enum OrderDirection { input PostUpdateInput { title: String author: AuthorRelateToOneForUpdateInput + related: PostRelateToManyForUpdateInput tags: TagRelateToManyForUpdateInput } @@ -90,6 +100,13 @@ input AuthorRelateToOneForUpdateInput { disconnect: Boolean } +input PostRelateToManyForUpdateInput { + disconnect: [PostWhereUniqueInput!] + set: [PostWhereUniqueInput!] + create: [PostCreateInput!] + connect: [PostWhereUniqueInput!] +} + input TagRelateToManyForUpdateInput { disconnect: [TagWhereUniqueInput!] set: [TagWhereUniqueInput!] @@ -105,6 +122,7 @@ input PostUpdateArgs { input PostCreateInput { title: String author: AuthorRelateToOneForCreateInput + related: PostRelateToManyForCreateInput tags: TagRelateToManyForCreateInput } @@ -113,6 +131,11 @@ input AuthorRelateToOneForCreateInput { connect: AuthorWhereUniqueInput } +input PostRelateToManyForCreateInput { + create: [PostCreateInput!] + connect: [PostWhereUniqueInput!] +} + input TagRelateToManyForCreateInput { create: [TagCreateInput!] connect: [TagWhereUniqueInput!] @@ -173,12 +196,6 @@ input TagWhereInput { posts: PostManyRelationFilter } -input PostManyRelationFilter { - every: PostWhereInput - some: PostWhereInput - none: PostWhereInput -} - input TagOrderByInput { id: OrderDirection name: OrderDirection @@ -189,13 +206,6 @@ input TagUpdateInput { posts: PostRelateToManyForUpdateInput } -input PostRelateToManyForUpdateInput { - disconnect: [PostWhereUniqueInput!] - set: [PostWhereUniqueInput!] - create: [PostCreateInput!] - connect: [PostWhereUniqueInput!] -} - input TagUpdateArgs { where: TagWhereUniqueInput! data: TagUpdateInput! @@ -206,11 +216,6 @@ input TagCreateInput { posts: PostRelateToManyForCreateInput } -input PostRelateToManyForCreateInput { - create: [PostCreateInput!] - connect: [PostWhereUniqueInput!] -} - """ The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). """ diff --git a/examples/better-list-search/schema.prisma b/examples/better-list-search/schema.prisma index 85aa33f550b..51653e5f76e 100644 --- a/examples/better-list-search/schema.prisma +++ b/examples/better-list-search/schema.prisma @@ -13,11 +13,13 @@ generator client { } model Post { - id String @id @default(cuid()) - title String @default("") - author Author? @relation("Post_author", fields: [authorId], references: [id]) - authorId String? @map("author") - tags Tag[] @relation("Post_tags") + id String @id @default(cuid()) + title String @default("") + author Author? @relation("Post_author", fields: [authorId], references: [id]) + authorId String? @map("author") + related Post[] @relation("Post_related") + tags Tag[] @relation("Post_tags") + from_Post_related Post[] @relation("Post_related") @@index([authorId]) } diff --git a/examples/better-list-search/schema.ts b/examples/better-list-search/schema.ts index bf45b8aaa39..c5983d18de2 100644 --- a/examples/better-list-search/schema.ts +++ b/examples/better-list-search/schema.ts @@ -14,6 +14,10 @@ export const lists = { author: relationship({ ref: 'Author', }), + related: relationship({ + ref: 'Post', + many: true, + }), tags: relationship({ ref: 'Tag.posts', many: true, diff --git a/packages/core/src/fields/types/relationship/index.ts b/packages/core/src/fields/types/relationship/index.ts index cce8164d6e5..617ee14ce96 100644 --- a/packages/core/src/fields/types/relationship/index.ts +++ b/packages/core/src/fields/types/relationship/index.ts @@ -137,18 +137,16 @@ export function 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) + const refLabelField = foreignListMeta.labelField + const refSearchFields = foreignListMeta.initialSearch if (config.ui?.displayMode === 'count') { return { + displayMode: 'count', refFieldKey: foreignFieldKey, refListKey: foreignListKey, many, hideCreate, - displayMode: 'count', refLabelField, refSearchFields, } @@ -189,11 +187,11 @@ export function relationship ({ } return { + displayMode: 'cards', refFieldKey: foreignFieldKey, refListKey: foreignListKey, many, hideCreate, - displayMode: 'cards', cardFields: config.ui.cardFields, linkToItem: config.ui.linkToItem ?? false, removeMode: config.ui.removeMode ?? 'disconnect', @@ -221,21 +219,14 @@ export function relationship ({ `The ui.searchFields option for relationship field '${listKey}.${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 '${listKey}.${fieldKey}' includes '${searchFieldKey}' but that field doesn't have a contains filter that accepts a GraphQL String` - ) } return { + displayMode: 'select', refFieldKey: foreignFieldKey, refListKey: foreignListKey, many, hideCreate, - displayMode: 'select', refLabelField: specificRefLabelField, refSearchFields: specificRefSearchFields, } diff --git a/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx b/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx index 94507dc5ebc..626535019ba 100644 --- a/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx +++ b/packages/core/src/fields/types/relationship/views/RelationshipSelect.tsx @@ -75,9 +75,14 @@ function isUuid (x: unknown) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(x) } -export function useSearchFilter (value: string, list: ListMeta, searchFields: string[], lists: { - [list: string]: ListMeta -}) { +export function useSearchFilter ( + value: string, + list: ListMeta, + searchFields: string[], + lists: { + [list: string]: ListMeta + } +) { return useMemo(() => { const trimmedSearch = value.trim() if (!trimmedSearch.length) return { OR: [] } @@ -311,14 +316,15 @@ export function RelationshipSelect ({ { items: { [idFieldAlias]: string, [labelFieldAlias]: string | null }[] }, { where: Record, 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) { - ${labelFieldAlias}: ${labelField} - ${idFieldAlias}: id - ${extraSelection} - } - } - ` + query RelationshipSelectMore($where: ${list.gqlNames.whereInputName}!, $take: Int!, $skip: Int!) { + items: ${list.gqlNames.listQueryName}(where: $where, take: $take, skip: $skip) { + ${labelFieldAlias}: ${labelField} + ${idFieldAlias}: id + ${extraSelection} + } + } + ` + setLastFetchMore({ extraSelection, list, skip, where }) fetchMore({ query: QUERY,