diff --git a/.changeset/add-field-hooks.md b/.changeset/add-field-hooks.md new file mode 100644 index 00000000000..4af4b57ee93 --- /dev/null +++ b/.changeset/add-field-hooks.md @@ -0,0 +1,5 @@ +--- +"@keystone-6/core": minor +--- + +Add type `FieldHooks` to `@keystone-6/core/types` exports diff --git a/.changeset/fix-id-filters.md b/.changeset/fix-id-filters.md index 31ec8609cfa..dba37487317 100644 --- a/.changeset/fix-id-filters.md +++ b/.changeset/fix-id-filters.md @@ -2,4 +2,4 @@ "@keystone-6/core": patch --- -Fixes `Input error: only a int can be passed to id filters` for AdminUI +Fix `Input error: only a int can be passed to id filters` for AdminUI diff --git a/.changeset/odd-lemons-hide.md b/.changeset/odd-lemons-hide.md index 4b9e075e547..e28d968f972 100644 --- a/.changeset/odd-lemons-hide.md +++ b/.changeset/odd-lemons-hide.md @@ -2,4 +2,4 @@ '@keystone-6/core': patch --- -Fixes `hooks.validateInput` argument types for update operations +Fix `hooks.validateInput` argument types for update operations diff --git a/.changeset/strong-swans-destroy.md b/.changeset/strong-swans-destroy.md index 7c1d6e9dd4a..87a4f2cd878 100644 --- a/.changeset/strong-swans-destroy.md +++ b/.changeset/strong-swans-destroy.md @@ -2,4 +2,4 @@ '@keystone-6/core': patch --- -Removed deprecated `experimental.appDir` flag from generated next.config +Remove deprecated `experimental.appDir` flag from generated next.config diff --git a/examples/default-values/README.md b/examples/default-values/README.md index bdb5bf0494d..1d5c909e281 100644 --- a/examples/default-values/README.md +++ b/examples/default-values/README.md @@ -1,7 +1,8 @@ ## Feature Example - Default Values -This project demonstrates how to use default values for fields. -It builds on the [Task Manager](../task-manager) starter project. +This example demonstrates how to use default values for fields. + +This example builds on the [TODO](../usecase-todo) starter project. ## Instructions @@ -11,10 +12,9 @@ To run this project, clone the Keystone repository locally, run `pnpm` at the ro 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. +This will start Keystone’s Admin UI at [localhost:3000](http://localhost:3000), where you can add items to an empty 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. +When `NODE_ENV` is not equal to `production`, by default you can play with the GraphQL Playground at [localhost:3000/api/graphql](http://localhost:3000/api/graphql). ## Features diff --git a/examples/default-values/schema.graphql b/examples/default-values/schema.graphql index d42f50728e7..8cebb817bb7 100644 --- a/examples/default-values/schema.graphql +++ b/examples/default-values/schema.graphql @@ -168,6 +168,7 @@ type Person { input PersonWhereUniqueInput { id: ID + name: String } input PersonWhereInput { diff --git a/examples/default-values/schema.prisma b/examples/default-values/schema.prisma index fb6a23d46a8..cc47bf55c93 100644 --- a/examples/default-values/schema.prisma +++ b/examples/default-values/schema.prisma @@ -27,6 +27,6 @@ model Task { model Person { id String @id @default(cuid()) - name String @default("") + name String @unique @default("") tasks Task[] @relation("Task_assignedTo") } diff --git a/examples/default-values/schema.ts b/examples/default-values/schema.ts index ebfb879d93c..99ca5a229a8 100644 --- a/examples/default-values/schema.ts +++ b/examples/default-values/schema.ts @@ -78,7 +78,7 @@ export const lists: Lists = { Person: list({ access: allowAll, fields: { - name: text({ validation: { isRequired: true } }), + name: text({ validation: { isRequired: true }, isIndexed: 'unique' }), tasks: relationship({ ref: 'Task.assignedTo', many: true }), }, }), diff --git a/examples/hooks/schema.ts b/examples/hooks/schema.ts index 614b789a9dc..c614073c406 100644 --- a/examples/hooks/schema.ts +++ b/examples/hooks/schema.ts @@ -21,13 +21,13 @@ const readOnly = { }, ui: { createView: { - fieldMode: (args: unknown) => 'hidden' as const, + fieldMode: () => 'hidden' as const, }, itemView: { - fieldMode: (args: unknown) => 'read' as const, + fieldMode: () => 'read' as const, }, listView: { - fieldMode: (args: unknown) => 'read' as const, + fieldMode: () => 'read' as const, }, }, }; diff --git a/examples/reuse/README.md b/examples/reuse/README.md new file mode 100644 index 00000000000..beb3454b114 --- /dev/null +++ b/examples/reuse/README.md @@ -0,0 +1,22 @@ +## Base Project - Reuse + +This example demonstrates how to reuse lists, fields, hooks and other functions. +Navigatingn the types of these primitives can be difficult without experience, so hopefully this project helps you understand how you can apply this to your project. + +Reuse is not encouraged typically, but it can be helpful in projects that have grown beyond having everything inline. + +## Instructions + +To run this project, clone the Keystone repository locally, run `pnpm` at the root of the repository then navigate to this directory and run: + +```shell +pnpm dev +``` + +This will start Keystone’s Admin UI at [localhost:3000](http://localhost:3000), where you can add items to an empty database. + +When `NODE_ENV` is not equal to `production`, by default you can play with the GraphQL Playground at [localhost:3000/api/graphql](http://localhost:3000/api/graphql). + +## 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/reuse/keystone.ts b/examples/reuse/keystone.ts new file mode 100644 index 00000000000..5ef2ee1f7db --- /dev/null +++ b/examples/reuse/keystone.ts @@ -0,0 +1,14 @@ +import { config } from '@keystone-6/core'; +import { fixPrismaPath } from '../example-utils'; +import { lists } from './schema'; + +export default config({ + db: { + provider: 'sqlite', + url: process.env.DATABASE_URL || 'file:./keystone-example.db', + + // WARNING: this is only needed for our monorepo examples, don't do this + ...fixPrismaPath, + }, + lists, +}); diff --git a/examples/reuse/package.json b/examples/reuse/package.json new file mode 100644 index 00000000000..e59f848bc48 --- /dev/null +++ b/examples/reuse/package.json @@ -0,0 +1,20 @@ +{ + "name": "@keystone-6/example-reuse", + "version": "0.0.1", + "private": true, + "license": "MIT", + "scripts": { + "dev": "keystone dev", + "start": "keystone start", + "build": "keystone build", + "postinstall": "keystone postinstall" + }, + "dependencies": { + "@keystone-6/core": "^5.0.0", + "@prisma/client": "^4.16.2" + }, + "devDependencies": { + "prisma": "^4.16.2", + "typescript": "~5.0.0" + } +} diff --git a/examples/reuse/sandbox.config.json b/examples/reuse/sandbox.config.json new file mode 100644 index 00000000000..ecd53f91829 --- /dev/null +++ b/examples/reuse/sandbox.config.json @@ -0,0 +1,7 @@ +{ + "template": "node", + "container": { + "startScript": "keystone dev", + "node": "18" + } +} diff --git a/examples/reuse/schema.graphql b/examples/reuse/schema.graphql new file mode 100644 index 00000000000..a0038899353 --- /dev/null +++ b/examples/reuse/schema.graphql @@ -0,0 +1,349 @@ +# This file is automatically generated by Keystone, do not modify it manually. +# Modify your Keystone config when you want to change this. + +type Invoice { + id: ID! + title: String + completed: Boolean + createdBy: String + createdAt: DateTime + updatedBy: String + updatedAt: DateTime +} + +scalar DateTime @specifiedBy(url: "https://datatracker.ietf.org/doc/html/rfc3339#section-5.6") + +input InvoiceWhereUniqueInput { + id: ID +} + +input InvoiceWhereInput { + AND: [InvoiceWhereInput!] + OR: [InvoiceWhereInput!] + NOT: [InvoiceWhereInput!] + id: IDFilter + title: StringFilter + completed: BooleanFilter + createdBy: StringFilter + createdAt: DateTimeNullableFilter + updatedBy: StringFilter + updatedAt: DateTimeNullableFilter +} + +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 BooleanFilter { + equals: Boolean + not: BooleanFilter +} + +input DateTimeNullableFilter { + equals: DateTime + in: [DateTime!] + notIn: [DateTime!] + lt: DateTime + lte: DateTime + gt: DateTime + gte: DateTime + not: DateTimeNullableFilter +} + +input InvoiceOrderByInput { + id: OrderDirection + title: OrderDirection + completed: OrderDirection + createdBy: OrderDirection + createdAt: OrderDirection + updatedBy: OrderDirection + updatedAt: OrderDirection +} + +enum OrderDirection { + asc + desc +} + +input InvoiceUpdateInput { + title: String + completed: Boolean +} + +input InvoiceUpdateArgs { + where: InvoiceWhereUniqueInput! + data: InvoiceUpdateInput! +} + +input InvoiceCreateInput { + title: String + completed: Boolean +} + +type Order { + id: ID! + title: String + completed: Boolean + createdBy: String + createdAt: DateTime + updatedBy: String + updatedAt: DateTime +} + +input OrderWhereUniqueInput { + id: ID +} + +input OrderWhereInput { + AND: [OrderWhereInput!] + OR: [OrderWhereInput!] + NOT: [OrderWhereInput!] + id: IDFilter + title: StringFilter + completed: BooleanFilter + createdBy: StringFilter + createdAt: DateTimeNullableFilter + updatedBy: StringFilter + updatedAt: DateTimeNullableFilter +} + +input OrderOrderByInput { + id: OrderDirection + title: OrderDirection + completed: OrderDirection + createdBy: OrderDirection + createdAt: OrderDirection + updatedBy: OrderDirection + updatedAt: OrderDirection +} + +input OrderUpdateInput { + title: String + completed: Boolean +} + +input OrderUpdateArgs { + where: OrderWhereUniqueInput! + data: OrderUpdateInput! +} + +input OrderCreateInput { + title: String + completed: Boolean +} + +type User { + id: ID! + name: String +} + +input UserWhereUniqueInput { + id: ID +} + +input UserWhereInput { + AND: [UserWhereInput!] + OR: [UserWhereInput!] + NOT: [UserWhereInput!] + id: IDFilter + name: StringFilter +} + +input UserOrderByInput { + id: OrderDirection + name: OrderDirection +} + +input UserUpdateInput { + name: String +} + +input UserUpdateArgs { + where: UserWhereUniqueInput! + data: UserUpdateInput! +} + +input UserCreateInput { + name: String +} + +""" +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 { + createInvoice(data: InvoiceCreateInput!): Invoice + createInvoices(data: [InvoiceCreateInput!]!): [Invoice] + updateInvoice(where: InvoiceWhereUniqueInput!, data: InvoiceUpdateInput!): Invoice + updateInvoices(data: [InvoiceUpdateArgs!]!): [Invoice] + deleteInvoice(where: InvoiceWhereUniqueInput!): Invoice + deleteInvoices(where: [InvoiceWhereUniqueInput!]!): [Invoice] + createOrder(data: OrderCreateInput!): Order + createOrders(data: [OrderCreateInput!]!): [Order] + updateOrder(where: OrderWhereUniqueInput!, data: OrderUpdateInput!): Order + updateOrders(data: [OrderUpdateArgs!]!): [Order] + deleteOrder(where: OrderWhereUniqueInput!): Order + deleteOrders(where: [OrderWhereUniqueInput!]!): [Order] + createUser(data: UserCreateInput!): User + createUsers(data: [UserCreateInput!]!): [User] + updateUser(where: UserWhereUniqueInput!, data: UserUpdateInput!): User + updateUsers(data: [UserUpdateArgs!]!): [User] + deleteUser(where: UserWhereUniqueInput!): User + deleteUsers(where: [UserWhereUniqueInput!]!): [User] +} + +type Query { + invoices(where: InvoiceWhereInput! = {}, orderBy: [InvoiceOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: InvoiceWhereUniqueInput): [Invoice!] + invoice(where: InvoiceWhereUniqueInput!): Invoice + invoicesCount(where: InvoiceWhereInput! = {}): Int + orders(where: OrderWhereInput! = {}, orderBy: [OrderOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: OrderWhereUniqueInput): [Order!] + order(where: OrderWhereUniqueInput!): Order + ordersCount(where: OrderWhereInput! = {}): Int + users(where: UserWhereInput! = {}, orderBy: [UserOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: UserWhereUniqueInput): [User!] + user(where: UserWhereUniqueInput!): User + usersCount(where: UserWhereInput! = {}): Int + keystone: KeystoneMeta! +} + +type KeystoneMeta { + adminMeta: KeystoneAdminMeta! +} + +type KeystoneAdminMeta { + lists: [KeystoneAdminUIListMeta!]! + list(key: String!): KeystoneAdminUIListMeta +} + +type KeystoneAdminUIListMeta { + key: String! + itemQueryName: String! + listQueryName: String! + hideCreate: Boolean! + hideDelete: Boolean! + path: String! + label: String! + singular: String! + plural: String! + description: String + initialColumns: [String!]! + pageSize: Int! + labelField: String! + fields: [KeystoneAdminUIFieldMeta!]! + groups: [KeystoneAdminUIFieldGroupMeta!]! + initialSort: KeystoneAdminUISort + isHidden: Boolean! + isSingleton: Boolean! +} + +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 KeystoneAdminUISort { + field: String! + direction: KeystoneAdminUISortDirection! +} + +enum KeystoneAdminUISortDirection { + ASC + DESC +} diff --git a/examples/reuse/schema.prisma b/examples/reuse/schema.prisma new file mode 100644 index 00000000000..b209e5bebf7 --- /dev/null +++ b/examples/reuse/schema.prisma @@ -0,0 +1,38 @@ +// 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/client" +} + +model Invoice { + id String @id @default(cuid()) + title String @default("") + completed Boolean @default(false) + createdBy String @default("") + createdAt DateTime? + updatedBy String @default("") + updatedAt DateTime? +} + +model Order { + id String @id @default(cuid()) + title String @default("") + completed Boolean @default(false) + createdBy String @default("") + createdAt DateTime? + updatedBy String @default("") + updatedAt DateTime? +} + +model User { + id String @id @default(cuid()) + name String @default("") +} diff --git a/examples/reuse/schema.ts b/examples/reuse/schema.ts new file mode 100644 index 00000000000..163e74ce6cc --- /dev/null +++ b/examples/reuse/schema.ts @@ -0,0 +1,147 @@ +import { list } from '@keystone-6/core'; +// import type { BaseListTypeInfo } from '@keystone-6/core/types'; +import type { FieldHooks } from '@keystone-6/core/types'; +import { allowAll, denyAll } from '@keystone-6/core/access'; +import { checkbox, text, timestamp } from '@keystone-6/core/fields'; +import type { Lists, TypeInfo } from '.keystone/types'; + +const readOnly = { + access: { + read: allowAll, + create: denyAll, + update: denyAll, + }, + graphql: { + omit: { + create: true, + update: true, + }, + }, + ui: { + createView: { + fieldMode: () => 'hidden' as const, + }, + itemView: { + fieldMode: () => 'read' as const, + fieldPosition: () => 'sidebar' as const, + }, + listView: { + fieldMode: () => 'read' as const, + }, + }, +}; + +// we use this function to show that completed is a boolean type +// which would be missing if the types were unrefined +// a common problem when re-using code +function isTrue(b: boolean) { + return b === true; +} + +type ListsT = TypeInfo['lists']; +type FindListsWithField = { + [key in keyof ListsT]: K extends ListsT[key]['fields'] ? ListsT[key] : never; +}[keyof ListsT]; + +// alternatively, if you don't like type functions +// type CompatibleLists = Lists.Invoice.TypeInfo | Lists.Order.TypeInfo +type CompatibleLists = FindListsWithField<'completed'>; +// type CompatibleLists = TypeInfo['lists'][keyof TypeInfo['lists']] // item is resolved, but not completed +// type CompatibleLists = BaseListTypeInfo // nothing is refined, item is Record + +function trackingByHooks< + ListTypeInfo extends CompatibleLists + // FieldKey extends 'createdBy' | 'updatedBy' // TODO: refined types for the return types +>(immutable: boolean = false): FieldHooks { + return { + async resolveInput({ context, operation, resolvedData, item, fieldKey }) { + if (operation === 'update') { + if (immutable) return undefined; + + // show we have refined types for compatible item.* fields + if (isTrue(item.completed) && resolvedData.completed !== false) return undefined; + } + + // TODO: refined types for the return types + // FIXME: CommonFieldConfig need not always be generalised + return `${context.req?.socket.remoteAddress} (${context.req?.headers['user-agent']})` as any; + }, + }; +} + +function trackingAtHooks< + ListTypeInfo extends CompatibleLists + // FieldKey extends 'createdAt' | 'updatedAt' // TODO: refined types for the return types +>(immutable: boolean = false): FieldHooks { + return { + // TODO: switch to operation routing when supported for fields + async resolveInput({ context, operation, resolvedData, item, fieldKey }) { + if (operation === 'update') { + if (immutable) return undefined; + + // show we have refined types for compatible item.* fields + if (isTrue(item.completed) && resolvedData.completed !== false) return undefined; + } + + // TODO: refined types for the return types + // FIXME: CommonFieldConfig need not always be generalised + return new Date() as any; + }, + }; +} + +function trackingFields() { + return { + createdBy: text({ + ...readOnly, + hooks: { + ...trackingByHooks(true), + }, + }), + createdAt: timestamp({ + ...readOnly, + hooks: { + ...trackingAtHooks(true), + }, + }), + updatedBy: text({ + ...readOnly, + hooks: { + ...trackingByHooks(), + }, + }), + updatedAt: timestamp({ + ...readOnly, + hooks: { + ...trackingAtHooks(), + }, + }), + }; +} + +export const lists: Lists = { + Invoice: list({ + access: allowAll, + fields: { + title: text(), + completed: checkbox(), + ...trackingFields(), + }, + }), + + Order: list({ + access: allowAll, + fields: { + title: text(), + completed: checkbox(), + ...trackingFields(), + }, + }), + + User: list({ + access: allowAll, + fields: { + name: text(), + }, + }), +}; diff --git a/examples/usecase-todo/schema.ts b/examples/usecase-todo/schema.ts index 753c606a6c1..c44f191ebaf 100644 --- a/examples/usecase-todo/schema.ts +++ b/examples/usecase-todo/schema.ts @@ -2,8 +2,9 @@ import { list } from '@keystone-6/core'; import { allowAll } from '@keystone-6/core/access'; import { checkbox, relationship, text, timestamp } from '@keystone-6/core/fields'; import { select } from '@keystone-6/core/fields'; +import type { Lists } from '.keystone/types'; -export const lists = { +export const lists: Lists = { Task: list({ access: allowAll, fields: { diff --git a/packages/core/src/types/config/index.ts b/packages/core/src/types/config/index.ts index 31cc9c71e3f..c2b06dfe12e 100644 --- a/packages/core/src/types/config/index.ts +++ b/packages/core/src/types/config/index.ts @@ -19,7 +19,7 @@ import type { } from './lists'; import type { BaseFields } from './fields'; import type { ListAccessControl, FieldAccessControl } from './access-control'; -import type { ListHooks } from './hooks'; +import type { ListHooks, FieldHooks } from './hooks'; type FileOrImage = // is given full file name, returns file name that will be used at @@ -331,7 +331,7 @@ export type ImagesConfig = { // Exports from sibling packages -export type { ListHooks, ListAccessControl, FieldAccessControl }; +export type { ListHooks, ListAccessControl, FieldHooks, FieldAccessControl }; export type { FieldCreateItemAccessArgs, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1acb9df994d..67ea82aa6ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1615,6 +1615,22 @@ importers: specifier: ~5.0.0 version: 5.0.2 + examples/reuse: + dependencies: + '@keystone-6/core': + specifier: ^5.0.0 + version: link:../../packages/core + '@prisma/client': + specifier: ^4.16.2 + version: 4.16.2(prisma@4.16.2) + devDependencies: + prisma: + specifier: ^4.16.2 + version: 4.16.2 + typescript: + specifier: ~5.0.0 + version: 5.0.2 + examples/script: dependencies: '@keystone-6/core':