From f662a5c995eded63d4b00cd08df89c3dc919360f Mon Sep 17 00:00:00 2001 From: mitchellhamilton Date: Wed, 22 Sep 2021 17:10:31 +1000 Subject: [PATCH 01/10] WIP --- .../keystone/src/fields/types/select/index.ts | 195 +++++++++++------- .../types/select/tests/test-fixtures.ts | 4 +- .../src/fields/types/select/views/index.tsx | 23 ++- .../keystone/src/lib/core/prisma-schema.ts | 2 +- packages/keystone/src/types/next-fields.ts | 4 +- 5 files changed, 144 insertions(+), 84 deletions(-) diff --git a/packages/keystone/src/fields/types/select/index.ts b/packages/keystone/src/fields/types/select/index.ts index af0b6f307d4..61113537f25 100644 --- a/packages/keystone/src/fields/types/select/index.ts +++ b/packages/keystone/src/fields/types/select/index.ts @@ -1,7 +1,7 @@ import inflection from 'inflection'; +import { humanize } from '../../../lib/utils'; import { BaseGeneratedListTypes, - FieldDefaultValue, fieldType, FieldTypeFunc, CommonFieldConfig, @@ -16,20 +16,37 @@ export type SelectFieldConfig & ( | { - options: { label: string; value: string }[]; - dataType?: 'string' | 'enum'; - defaultValue?: FieldDefaultValue; + /** + * When a value is provided as just a string, it will be formatted in the same way + * as field labels are to create the label. + */ + options: ({ label: string; value: string } | string)[]; + + /** + * If `enum` is provided on SQLite, it will use an enum in GraphQL but a string in the database. + */ + type?: 'string' | 'enum'; + defaultValue?: string; } | { options: { label: string; value: number }[]; - dataType: 'integer'; - defaultValue?: FieldDefaultValue; + type: 'integer'; + defaultValue?: number; } ) & { ui?: { displayMode?: 'select' | 'segmented-control'; }; - isRequired?: boolean; + /** + * @default true + */ + isNullable?: boolean; + validation?: { + /** + * @default false + */ + isRequired?: boolean; + }; isIndexed?: boolean | 'unique'; }; @@ -37,8 +54,8 @@ export const select = ({ isIndexed, ui: { displayMode = 'select', ...ui } = {}, - isRequired, - defaultValue, + isNullable = true, + defaultValue: _defaultValue, ...config }: SelectFieldConfig): FieldTypeFunc => meta => { @@ -48,82 +65,112 @@ export const select = views: resolveView('select/views'), getAdminMeta: () => ({ options: config.options, - dataType: config.dataType ?? 'string', + kind: config.type === 'integer' ? 'integer' : 'string', displayMode: displayMode, + isRequired: config.validation?.isRequired ?? false, }), }; - const index = isIndexed === true ? 'index' : isIndexed || undefined; + const defaultValue = _defaultValue ?? null; - if (config.dataType === 'integer') { - return fieldType({ - kind: 'scalar', - scalar: 'Int', - mode: 'optional', - index, - })({ - ...commonConfig, - input: { - where: { - arg: graphql.arg({ type: filters[meta.provider].Int.optional }), - resolve: filters.resolveCommon, - }, - create: { arg: graphql.arg({ type: graphql.Int }) }, - update: { arg: graphql.arg({ type: graphql.Int }) }, - orderBy: { arg: graphql.arg({ type: orderDirectionEnum }) }, + const mode = isNullable === false ? 'required' : 'optional'; + const commonDbFieldOptions = { + mode, + index: isIndexed === true ? 'index' : isIndexed || undefined, + default: + defaultValue === null + ? undefined + : { kind: 'literal' as const, value: defaultValue as any }, + } as const; + + const options = config.options.map(option => { + if (typeof option === 'string') { + return { + label: humanize(option), + value: option, + }; + } + return option; + }); + + const enumName = `${meta.listKey}${inflection.classify(meta.fieldKey)}Type`; + + const dbField = + config.type === 'integer' + ? ({ kind: 'scalar', scalar: 'String', ...commonDbFieldOptions } as const) + : config.type === 'enum' && meta.provider !== 'sqlite' + ? ({ + kind: 'enum', + values: options.map(x => x.value as string), + name: enumName, + ...commonDbFieldOptions, + } as const) + : ({ kind: 'scalar', scalar: 'String', ...commonDbFieldOptions } as const); + + const graphQLType = + config.type === 'integer' + ? graphql.Int + : config.type === 'enum' + ? graphql.enum({ + name: enumName, + values: graphql.enumValues(options.map(x => x.value as string)), + }) + : graphql.String; + + const values = new Set(options.map(x => x.value)); + + const fieldLabel = config.label ?? humanize(meta.fieldKey); + + return fieldType(dbField)({ + ...commonConfig, + hooks: { + ...config.hooks, + async validateInput(args) { + const value = args.resolvedData[meta.fieldKey]; + if (value != null) { + if (!values.has(value)) { + args.addValidationError(`${value} is not a possible value for ${fieldLabel}`); + } + } + if ( + config.validation?.isRequired && + (value === null || (value === undefined && args.operation === 'create')) + ) { + args.addValidationError(`${fieldLabel} is required`); + } + await config.hooks?.validateInput?.(args); }, - output: graphql.field({ type: graphql.Int }), - __legacy: { defaultValue, isRequired }, - }); - } - if (config.dataType === 'enum') { - const enumName = `${meta.listKey}${inflection.classify(meta.fieldKey)}Type`; - const graphQLType = graphql.enum({ - name: enumName, - values: graphql.enumValues(config.options.map(x => x.value)), - }); - // i do not like this "let's just magically use strings on sqlite" - return fieldType( - meta.provider === 'sqlite' - ? { kind: 'scalar', scalar: 'String', mode: 'optional', index } + }, + input: { + where: (config.type === undefined || config.type === 'string' + ? { + arg: graphql.arg({ type: filters[meta.provider].String[mode] }), + resolve: mode === 'optional' ? filters.resolveString : undefined, + } : { - kind: 'enum', - values: config.options.map(x => x.value), - mode: 'optional', - name: enumName, - index, + arg: graphql.arg({ + type: + graphQLType.kind === 'enum' + ? // while the enum filters are technically postgres only + // the enum filters are essentially a subset of + // the string filters so this is fine + filters.postgresql.enum(graphQLType)[mode] + : filters[meta.provider].Int[mode], + }), + resolve: mode === 'optional' ? filters.resolveCommon : undefined, + }) as any, + create: { + arg: graphql.arg({ type: graphQLType }), + resolve(val) { + if (val === undefined) { + return defaultValue; } - )({ - ...commonConfig, - input: { - where: { - arg: graphql.arg({ type: filters[meta.provider].enum(graphQLType).optional }), - resolve: filters.resolveCommon, + return val; }, - create: { arg: graphql.arg({ type: graphQLType }) }, - update: { arg: graphql.arg({ type: graphQLType }) }, - orderBy: { arg: graphql.arg({ type: orderDirectionEnum }) }, }, - output: graphql.field({ - type: graphQLType, - }), - __legacy: { defaultValue, isRequired }, - }); - } - return fieldType({ kind: 'scalar', scalar: 'String', mode: 'optional', index })({ - ...commonConfig, - input: { - where: { - arg: graphql.arg({ type: filters[meta.provider].String.optional }), - resolve: filters.resolveString, - }, - create: { arg: graphql.arg({ type: graphql.String }) }, - update: { arg: graphql.arg({ type: graphql.String }) }, + update: { arg: graphql.arg({ type: graphQLType }) }, orderBy: { arg: graphql.arg({ type: orderDirectionEnum }) }, }, - output: graphql.field({ - type: graphql.String, - }), - __legacy: { defaultValue, isRequired }, + output: graphql.field({ type: graphQLType }), }); }; diff --git a/packages/keystone/src/fields/types/select/tests/test-fixtures.ts b/packages/keystone/src/fields/types/select/tests/test-fixtures.ts index 3f97b3f4956..3f3f4be6bcb 100644 --- a/packages/keystone/src/fields/types/select/tests/test-fixtures.ts +++ b/packages/keystone/src/fields/types/select/tests/test-fixtures.ts @@ -13,7 +13,7 @@ export const fieldConfig = (matrixValue: MatrixValue) => { if (matrixValue === 'enum' || matrixValue === 'string') { return { isFilterable: true as const, - dataType: matrixValue, + type: matrixValue, options: matrixValue === 'enum' ? [ @@ -35,7 +35,7 @@ export const fieldConfig = (matrixValue: MatrixValue) => { } return { isFilterable: true as const, - dataType: matrixValue, + type: matrixValue, options: [ { label: 'One', value: 1 }, { label: 'Two', value: 2 }, diff --git a/packages/keystone/src/fields/types/select/views/index.tsx b/packages/keystone/src/fields/types/select/views/index.tsx index efbf1d2db77..c4cbedcea45 100644 --- a/packages/keystone/src/fields/types/select/views/index.tsx +++ b/packages/keystone/src/fields/types/select/views/index.tsx @@ -1,9 +1,10 @@ /** @jsxRuntime classic */ /** @jsx jsx */ import { Fragment } from 'react'; -import { jsx } from '@keystone-ui/core'; +import { jsx, VisuallyHidden } from '@keystone-ui/core'; import { FieldContainer, FieldLabel, MultiSelect, Select } from '@keystone-ui/fields'; import { SegmentedControl } from '@keystone-ui/segmented-control'; +import { XIcon } from '@keystone-ui/icons/icons/XIcon'; import { CardValueComponent, CellComponent, @@ -41,6 +42,17 @@ export const Field = ({ field, value, onChange, autoFocus }: FieldProps + {value !== null && onChange !== undefined && ( + + )} )} @@ -66,15 +78,16 @@ export const CardValue: CardValueComponent = ({ item, field } type Config = FieldControllerConfig<{ options: { label: string; value: string | number }[]; - dataType: 'string' | 'enum' | 'integer'; + kind: 'string' | 'integer'; displayMode: 'select' | 'segmented-control'; + isRequired: boolean; }>; export const controller = ( config: Config ): FieldController<{ label: string; value: string } | null, { label: string; value: string }[]> & { options: { label: string; value: string }[]; - dataType: 'string' | 'enum' | 'integer'; + kind: 'string' | 'integer'; displayMode: 'select' | 'segmented-control'; } => { const optionsWithStringValues = config.fieldMeta.options.map(x => ({ @@ -84,14 +97,14 @@ export const controller = ( // Transform from string value to dataType appropriate value const t = (v: string | null) => - v === null ? null : config.fieldMeta.dataType === 'integer' ? parseInt(v) : v; + v === null ? null : config.fieldMeta.kind === 'integer' ? parseInt(v) : v; return { path: config.path, label: config.label, graphqlSelection: config.path, defaultValue: null, - dataType: config.fieldMeta.dataType, + kind: config.fieldMeta.kind, displayMode: config.fieldMeta.displayMode, options: optionsWithStringValues, deserialize: data => { diff --git a/packages/keystone/src/lib/core/prisma-schema.ts b/packages/keystone/src/lib/core/prisma-schema.ts index b49caf57fe8..bfe696da98c 100644 --- a/packages/keystone/src/lib/core/prisma-schema.ts +++ b/packages/keystone/src/lib/core/prisma-schema.ts @@ -75,7 +75,7 @@ function printField( } if (field.kind === 'enum') { const index = printIndex(fieldPath, field.index); - const defaultValue = field.default ? ` @default(${field.default})` : ''; + const defaultValue = field.default ? ` @default(${field.default.value})` : ''; return `${fieldPath} ${field.name}${modifiers[field.mode]}${defaultValue}${index}`; } if (field.kind === 'multi') { diff --git a/packages/keystone/src/types/next-fields.ts b/packages/keystone/src/types/next-fields.ts index 1891296c93c..e343f3a7dff 100644 --- a/packages/keystone/src/types/next-fields.ts +++ b/packages/keystone/src/types/next-fields.ts @@ -121,7 +121,7 @@ export type EnumDBField = ( value: Input, context: KeystoneContext, relationshipInputResolver: RelationshipInputResolver -) => MaybePromise; +) => Output; // eslint-disable-next-line @typescript-eslint/no-unused-vars type DBFieldFiltersInner = Record; From 6a2f16a068cbc425353aa86c34a866d2af2d9552 Mon Sep 17 00:00:00 2001 From: mitchellhamilton Date: Thu, 23 Sep 2021 09:12:17 +1000 Subject: [PATCH 02/10] Revert a thing --- packages/keystone/src/types/next-fields.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/keystone/src/types/next-fields.ts b/packages/keystone/src/types/next-fields.ts index e343f3a7dff..30e7ea1c89b 100644 --- a/packages/keystone/src/types/next-fields.ts +++ b/packages/keystone/src/types/next-fields.ts @@ -220,7 +220,7 @@ type FieldInputResolver = ( value: Input, context: KeystoneContext, relationshipInputResolver: RelationshipInputResolver -) => Output; +) => MaybePromise; // eslint-disable-next-line @typescript-eslint/no-unused-vars type DBFieldFiltersInner = Record; From 40269433373806d7694bb1e7352494ce319ed2e2 Mon Sep 17 00:00:00 2001 From: mitchellhamilton Date: Thu, 23 Sep 2021 09:27:36 +1000 Subject: [PATCH 03/10] WIP --- packages/keystone/src/fields/types/select/index.ts | 2 +- packages/keystone/src/fields/types/select/views/index.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/keystone/src/fields/types/select/index.ts b/packages/keystone/src/fields/types/select/index.ts index 61113537f25..509d48192a6 100644 --- a/packages/keystone/src/fields/types/select/index.ts +++ b/packages/keystone/src/fields/types/select/index.ts @@ -65,7 +65,7 @@ export const select = views: resolveView('select/views'), getAdminMeta: () => ({ options: config.options, - kind: config.type === 'integer' ? 'integer' : 'string', + kind: config.type ?? 'string', displayMode: displayMode, isRequired: config.validation?.isRequired ?? false, }), diff --git a/packages/keystone/src/fields/types/select/views/index.tsx b/packages/keystone/src/fields/types/select/views/index.tsx index c4cbedcea45..a020b5df15c 100644 --- a/packages/keystone/src/fields/types/select/views/index.tsx +++ b/packages/keystone/src/fields/types/select/views/index.tsx @@ -78,7 +78,7 @@ export const CardValue: CardValueComponent = ({ item, field } type Config = FieldControllerConfig<{ options: { label: string; value: string | number }[]; - kind: 'string' | 'integer'; + type: 'string' | 'integer' | 'enum'; displayMode: 'select' | 'segmented-control'; isRequired: boolean; }>; @@ -87,7 +87,7 @@ export const controller = ( config: Config ): FieldController<{ label: string; value: string } | null, { label: string; value: string }[]> & { options: { label: string; value: string }[]; - kind: 'string' | 'integer'; + type: 'string' | 'integer' | 'enum'; displayMode: 'select' | 'segmented-control'; } => { const optionsWithStringValues = config.fieldMeta.options.map(x => ({ @@ -97,14 +97,14 @@ export const controller = ( // Transform from string value to dataType appropriate value const t = (v: string | null) => - v === null ? null : config.fieldMeta.kind === 'integer' ? parseInt(v) : v; + v === null ? null : config.fieldMeta.type === 'integer' ? parseInt(v) : v; return { path: config.path, label: config.label, graphqlSelection: config.path, defaultValue: null, - kind: config.fieldMeta.kind, + type: config.fieldMeta.type, displayMode: config.fieldMeta.displayMode, options: optionsWithStringValues, deserialize: data => { From 3c0d9eb76d125fceeb5f71c762a1a3135b78b0cd Mon Sep 17 00:00:00 2001 From: mitchellhamilton Date: Thu, 23 Sep 2021 11:27:07 +1000 Subject: [PATCH 04/10] Things --- .../keystone/src/fields/types/select/index.ts | 207 ++++++++++-------- .../src/fields/types/select/views/index.tsx | 11 +- 2 files changed, 123 insertions(+), 95 deletions(-) diff --git a/packages/keystone/src/fields/types/select/index.ts b/packages/keystone/src/fields/types/select/index.ts index 509d48192a6..bb021021c26 100644 --- a/packages/keystone/src/fields/types/select/index.ts +++ b/packages/keystone/src/fields/types/select/index.ts @@ -21,7 +21,6 @@ export type SelectFieldConfig({ isIndexed, ui: { displayMode = 'select', ...ui } = {}, - isNullable = true, - defaultValue: _defaultValue, + defaultValue, + validation, ...config }: SelectFieldConfig): FieldTypeFunc => meta => { - const commonConfig = { - ...config, - ui, - views: resolveView('select/views'), - getAdminMeta: () => ({ - options: config.options, - kind: config.type ?? 'string', - displayMode: displayMode, - isRequired: config.validation?.isRequired ?? false, - }), + const fieldLabel = config.label ?? humanize(meta.fieldKey); + const commonConfig = ( + options: { value: string | number; label: string }[] + ): CommonFieldConfig & { + views: string; + getAdminMeta: () => import('./views').AdminSelectFieldMeta; + } => { + const values = new Set(options.map(x => x.value)); + return { + ...config, + ui, + hooks: { + ...config.hooks, + async validateInput(args) { + const value = args.resolvedData[meta.fieldKey]; + if (value != null && !values.has(value)) { + args.addValidationError(`${value} is not a possible value for ${fieldLabel}`); + } + if ( + validation?.isRequired && + (value === null || (value === undefined && args.operation === 'create')) + ) { + args.addValidationError(`${fieldLabel} is required`); + } + await config.hooks?.validateInput?.(args); + }, + }, + views: resolveView('select/views'), + getAdminMeta: () => ({ + options, + type: config.type ?? 'string', + displayMode: displayMode, + defaultValue: defaultValue ?? null, + isRequired: validation?.isRequired ?? false, + }), + }; }; - - const defaultValue = _defaultValue ?? null; - - const mode = isNullable === false ? 'required' : 'optional'; - const commonDbFieldOptions = { + const mode = config.isNullable === false ? 'required' : 'optional'; + const commonDbFieldConfig = { mode, index: isIndexed === true ? 'index' : isIndexed || undefined, default: - defaultValue === null + defaultValue === undefined ? undefined : { kind: 'literal' as const, value: defaultValue as any }, } as const; + const resolveCreate = (val: T | null | undefined): T | null => { + if (val === undefined) { + return (defaultValue as T | undefined) ?? null; + } + return val; + }; + + if (config.type === 'integer') { + if ( + config.options.some( + ({ value }) => !Number.isInteger(value) || value > MAX_INT || value < MIN_INT + ) + ) { + throw new Error( + `The select field at ${meta.listKey}.${meta.fieldKey} specifies integer values that are outside the range of a 32 bit signed integer` + ); + } + return fieldType({ + kind: 'scalar', + scalar: 'Int', + ...commonDbFieldConfig, + })({ + ...commonConfig(config.options), + input: { + where: { + arg: graphql.arg({ type: filters[meta.provider].Int[mode] }), + resolve: mode === 'required' ? undefined : filters.resolveCommon, + }, + create: { arg: graphql.arg({ type: graphql.Int }), resolve: resolveCreate }, + update: { arg: graphql.arg({ type: graphql.Int }) }, + orderBy: { arg: graphql.arg({ type: orderDirectionEnum }) }, + }, + output: graphql.field({ type: graphql.Int }), + }); + } const options = config.options.map(option => { if (typeof option === 'string') { return { @@ -93,84 +154,46 @@ export const select = return option; }); - const enumName = `${meta.listKey}${inflection.classify(meta.fieldKey)}Type`; - - const dbField = - config.type === 'integer' - ? ({ kind: 'scalar', scalar: 'String', ...commonDbFieldOptions } as const) - : config.type === 'enum' && meta.provider !== 'sqlite' - ? ({ - kind: 'enum', - values: options.map(x => x.value as string), - name: enumName, - ...commonDbFieldOptions, - } as const) - : ({ kind: 'scalar', scalar: 'String', ...commonDbFieldOptions } as const); - - const graphQLType = - config.type === 'integer' - ? graphql.Int - : config.type === 'enum' - ? graphql.enum({ - name: enumName, - values: graphql.enumValues(options.map(x => x.value as string)), - }) - : graphql.String; - - const values = new Set(options.map(x => x.value)); - - const fieldLabel = config.label ?? humanize(meta.fieldKey); - - return fieldType(dbField)({ - ...commonConfig, - hooks: { - ...config.hooks, - async validateInput(args) { - const value = args.resolvedData[meta.fieldKey]; - if (value != null) { - if (!values.has(value)) { - args.addValidationError(`${value} is not a possible value for ${fieldLabel}`); - } - } - if ( - config.validation?.isRequired && - (value === null || (value === undefined && args.operation === 'create')) - ) { - args.addValidationError(`${fieldLabel} is required`); - } - await config.hooks?.validateInput?.(args); - }, - }, - input: { - where: (config.type === undefined || config.type === 'string' - ? { - arg: graphql.arg({ type: filters[meta.provider].String[mode] }), - resolve: mode === 'optional' ? filters.resolveString : undefined, - } + if (config.type === 'enum') { + const enumName = `${meta.listKey}${inflection.classify(meta.fieldKey)}Type`; + const graphQLType = graphql.enum({ + name: enumName, + values: graphql.enumValues(options.map(x => x.value)), + }); + return fieldType( + meta.provider === 'sqlite' + ? { kind: 'scalar', scalar: 'String', ...commonDbFieldConfig } : { - arg: graphql.arg({ - type: - graphQLType.kind === 'enum' - ? // while the enum filters are technically postgres only - // the enum filters are essentially a subset of - // the string filters so this is fine - filters.postgresql.enum(graphQLType)[mode] - : filters[meta.provider].Int[mode], - }), - resolve: mode === 'optional' ? filters.resolveCommon : undefined, - }) as any, - create: { - arg: graphql.arg({ type: graphQLType }), - resolve(val) { - if (val === undefined) { - return defaultValue; + kind: 'enum', + values: options.map(x => x.value), + name: enumName, + ...commonDbFieldConfig, } - return val; + )({ + ...commonConfig(options), + input: { + where: { + arg: graphql.arg({ type: filters[meta.provider].enum(graphQLType).optional }), + resolve: mode === 'required' ? undefined : filters.resolveCommon, }, + create: { arg: graphql.arg({ type: graphQLType }), resolve: resolveCreate }, + update: { arg: graphql.arg({ type: graphQLType }) }, + orderBy: { arg: graphql.arg({ type: orderDirectionEnum }) }, + }, + output: graphql.field({ type: graphQLType }), + }); + } + return fieldType({ kind: 'scalar', scalar: 'String', ...commonDbFieldConfig })({ + ...commonConfig(options), + input: { + where: { + arg: graphql.arg({ type: filters[meta.provider].String[mode] }), + resolve: mode === 'required' ? undefined : filters.resolveString, }, - update: { arg: graphql.arg({ type: graphQLType }) }, + create: { arg: graphql.arg({ type: graphql.String }), resolve: resolveCreate }, + update: { arg: graphql.arg({ type: graphql.String }) }, orderBy: { arg: graphql.arg({ type: orderDirectionEnum }) }, }, - output: graphql.field({ type: graphQLType }), + output: graphql.field({ type: graphql.String }), }); }; diff --git a/packages/keystone/src/fields/types/select/views/index.tsx b/packages/keystone/src/fields/types/select/views/index.tsx index a020b5df15c..11d2b6e6694 100644 --- a/packages/keystone/src/fields/types/select/views/index.tsx +++ b/packages/keystone/src/fields/types/select/views/index.tsx @@ -76,12 +76,15 @@ export const CardValue: CardValueComponent = ({ item, field } ); }; -type Config = FieldControllerConfig<{ +export type AdminSelectFieldMeta = { options: { label: string; value: string | number }[]; type: 'string' | 'integer' | 'enum'; displayMode: 'select' | 'segmented-control'; isRequired: boolean; -}>; + defaultValue: string | number | null; +}; + +type Config = FieldControllerConfig; export const controller = ( config: Config @@ -99,11 +102,13 @@ export const controller = ( const t = (v: string | null) => v === null ? null : config.fieldMeta.type === 'integer' ? parseInt(v) : v; + const stringifiedDefault = config.fieldMeta.defaultValue?.toString(); + return { path: config.path, label: config.label, graphqlSelection: config.path, - defaultValue: null, + defaultValue: optionsWithStringValues.find(x => x.value === stringifiedDefault) ?? null, type: config.fieldMeta.type, displayMode: config.fieldMeta.displayMode, options: optionsWithStringValues, From 1dd09fdd52a7ebe5600821bd512fd51591a09edc Mon Sep 17 00:00:00 2001 From: mitchellhamilton Date: Thu, 23 Sep 2021 14:01:50 +1000 Subject: [PATCH 05/10] Things --- .../src/SegmentedControl.tsx | 35 ++++++--------- .../keystone/src/fields/types/select/index.ts | 5 +++ .../src/fields/types/select/views/index.tsx | 43 ++++++++++--------- 3 files changed, 41 insertions(+), 42 deletions(-) diff --git a/design-system/packages/segmented-control/src/SegmentedControl.tsx b/design-system/packages/segmented-control/src/SegmentedControl.tsx index 57d07033845..875591c52df 100644 --- a/design-system/packages/segmented-control/src/SegmentedControl.tsx +++ b/design-system/packages/segmented-control/src/SegmentedControl.tsx @@ -13,7 +13,6 @@ */ import { - ChangeEvent, ChangeEventHandler, HTMLAttributes, ReactNode, @@ -21,6 +20,7 @@ import { useEffect, useRef, useState, + InputHTMLAttributes, } from 'react'; import { @@ -29,7 +29,6 @@ import { jsx, ManagedChangeHandler, useId, - useManagedState, useTheme, VisuallyHidden, css, @@ -47,14 +46,12 @@ type SegmentedControlProps = { animate?: boolean; /** Whether the controls should take up the full width of their container. */ fill?: boolean; - /** Provide an initial index for an uncontrolled segmented control. */ - initialIndex?: Index; /** Function to be called when one of the segments is selected. */ - onChange?: ManagedChangeHandler; + onChange: ManagedChangeHandler; /** Provide labels for each segment. */ segments: string[]; /** The the selected index of the segmented control. */ - selectedIndex?: Index; + selectedIndex: Index | undefined; /** The size of the controls. */ size?: SizeKey; /** The width of the controls. */ @@ -64,25 +61,15 @@ type SegmentedControlProps = { export const SegmentedControl = ({ animate = false, fill = false, - initialIndex: initialIndexProp = -1, - onChange: onChangeProp, + onChange, segments, size = 'medium', width = 'large', - selectedIndex: selectedIndexProp, + selectedIndex, ...props }: SegmentedControlProps) => { const rootRef = useRef(null); const [selectedRect, setSelectedRect] = useState({}); - const [selectedIndex, setIndex] = useManagedState( - selectedIndexProp, - initialIndexProp, - onChangeProp - ); - - const handleChange = (index: Index) => (event: ChangeEvent) => { - setIndex(index, event); - }; // Because we use radio buttons for the segments, they should share a unique `name` const name = String(useId()); @@ -141,7 +128,9 @@ export const SegmentedControl = ({ isSelected={isSelected} key={label} name={name} - onChange={handleChange(idx)} + onChange={event => { + onChange(idx, event); + }} size={size} value={idx} > @@ -194,16 +183,18 @@ const Root = forwardRef(({ fill, size, width, ...prop ); }); -type ItemProps = { +type BaseInputProps = { children: ReactNode; fill: boolean; isAnimated: boolean; isSelected: boolean; - onChange: ChangeEventHandler; + onChange: ChangeEventHandler; name: string; size: SizeKey; value: Index; -} & HTMLAttributes; +}; + +type ItemProps = BaseInputProps & Omit, keyof BaseInputProps>; const Item = (props: ItemProps) => { const { children, fill, isAnimated, isSelected, onChange, size, value, ...attrs } = props; diff --git a/packages/keystone/src/fields/types/select/index.ts b/packages/keystone/src/fields/types/select/index.ts index bb021021c26..fbd8019524a 100644 --- a/packages/keystone/src/fields/types/select/index.ts +++ b/packages/keystone/src/fields/types/select/index.ts @@ -70,6 +70,11 @@ export const select = getAdminMeta: () => import('./views').AdminSelectFieldMeta; } => { const values = new Set(options.map(x => x.value)); + if (values.size !== options.length) { + throw new Error( + `The select field at ${meta.listKey}.${meta.fieldKey} has duplicate options, this is not allowed` + ); + } return { ...config, ui, diff --git a/packages/keystone/src/fields/types/select/views/index.tsx b/packages/keystone/src/fields/types/select/views/index.tsx index 11d2b6e6694..a2ee07dc808 100644 --- a/packages/keystone/src/fields/types/select/views/index.tsx +++ b/packages/keystone/src/fields/types/select/views/index.tsx @@ -1,10 +1,10 @@ /** @jsxRuntime classic */ /** @jsx jsx */ import { Fragment } from 'react'; -import { jsx, VisuallyHidden } from '@keystone-ui/core'; +import { jsx, Stack } from '@keystone-ui/core'; import { FieldContainer, FieldLabel, MultiSelect, Select } from '@keystone-ui/fields'; import { SegmentedControl } from '@keystone-ui/segmented-control'; -import { XIcon } from '@keystone-ui/icons/icons/XIcon'; +import { Button } from '@keystone-ui/button'; import { CardValueComponent, CellComponent, @@ -35,24 +35,26 @@ export const Field = ({ field, value, onChange, autoFocus }: FieldProps {field.label} - x.label)} - selectedIndex={value ? field.options.findIndex(x => x.value === value.value) : undefined} - onChange={index => { - onChange?.(field.options[index]); - }} - /> - {value !== null && onChange !== undefined && ( - - )} + /> + {value !== null && onChange !== undefined && ( + + )} + )} @@ -66,7 +68,8 @@ export const Cell: CellComponent = ({ item, field, linkTo }) Cell.supportsLinkTo = true; export const CardValue: CardValueComponent = ({ item, field }) => { - const label = field.options.find(x => x.value === item[field.path])?.label; + let value = item[field.path] + ''; + const label = field.options.find(x => x.value === value)?.label; return ( From f3a04450b219db10399269d9968daa83be6f73e6 Mon Sep 17 00:00:00 2001 From: mitchellhamilton Date: Thu, 23 Sep 2021 14:46:34 +1000 Subject: [PATCH 06/10] WIP --- examples-staging/basic/schema.prisma | 2 +- examples-staging/basic/schema.ts | 5 + .../src/fields/types/select/views/index.tsx | 149 ++++++++++++------ 3 files changed, 105 insertions(+), 51 deletions(-) diff --git a/examples-staging/basic/schema.prisma b/examples-staging/basic/schema.prisma index 5cfc78c0b04..471cf0512fa 100644 --- a/examples-staging/basic/schema.prisma +++ b/examples-staging/basic/schema.prisma @@ -45,7 +45,7 @@ model PhoneNumber { model Post { id String @id @default(cuid()) title String? - status String? + status String @default("draft") content String @default("[{\"type\":\"paragraph\",\"children\":[{\"text\":\"\"}]}]") publishDate DateTime? author User? @relation("Post_author", fields: [authorId], references: [id]) diff --git a/examples-staging/basic/schema.ts b/examples-staging/basic/schema.ts index af60c822fa8..16e9a8c1d5b 100644 --- a/examples-staging/basic/schema.ts +++ b/examples-staging/basic/schema.ts @@ -143,6 +143,11 @@ export const lists = { ui: { displayMode: 'segmented-control', }, + isNullable: false, + validation: { + isRequired: true, + }, + defaultValue: 'draft', }), content: document({ ui: { views: require.resolve('./admin/fieldViews/Content.tsx') }, diff --git a/packages/keystone/src/fields/types/select/views/index.tsx b/packages/keystone/src/fields/types/select/views/index.tsx index a2ee07dc808..9114b7e444f 100644 --- a/packages/keystone/src/fields/types/select/views/index.tsx +++ b/packages/keystone/src/fields/types/select/views/index.tsx @@ -1,10 +1,11 @@ /** @jsxRuntime classic */ /** @jsx jsx */ -import { Fragment } from 'react'; +import { Fragment, useState } from 'react'; import { jsx, Stack } from '@keystone-ui/core'; import { FieldContainer, FieldLabel, MultiSelect, Select } from '@keystone-ui/fields'; import { SegmentedControl } from '@keystone-ui/segmented-control'; import { Button } from '@keystone-ui/button'; +import { Text } from '@keystone-ui/core'; import { CardValueComponent, CellComponent, @@ -14,51 +15,73 @@ import { } from '../../../../types'; import { CellContainer, CellLink } from '../../../../admin-ui/components'; -export const Field = ({ field, value, onChange, autoFocus }: FieldProps) => ( - - {field.displayMode === 'select' ? ( - - {field.label} - { + onChange?.({ ...value, value: newVal }); + setHasChanged(true); }} + value={value.value} + portalMenu /> - {value !== null && onChange !== undefined && ( - - )} - - - )} - -); + /> + {value.value !== null && onChange !== undefined && ( + + )} + + {validationMessage} + + )} + + ); +}; export const Cell: CellComponent = ({ item, field, linkTo }) => { let value = item[field.path] + ''; @@ -89,12 +112,31 @@ export type AdminSelectFieldMeta = { type Config = FieldControllerConfig; +type Option = { label: string; value: string }; + +type Value = + | { value: Option | null; kind: 'create' } + | { value: Option | null; initial: Option | null; kind: 'update' }; + +function validate(value: Value, isRequired: boolean) { + if (isRequired) { + // if you got null initially on the update screen, we want to allow saving + // since the user probably doesn't have read access control + if (value.kind === 'update' && value.initial === null) { + return true; + } + return value.value !== null; + } + return true; +} + export const controller = ( config: Config -): FieldController<{ label: string; value: string } | null, { label: string; value: string }[]> & { - options: { label: string; value: string }[]; +): FieldController & { + options: Option[]; type: 'string' | 'integer' | 'enum'; displayMode: 'select' | 'segmented-control'; + isRequired: boolean; } => { const optionsWithStringValues = config.fieldMeta.options.map(x => ({ label: x.label, @@ -111,22 +153,29 @@ export const controller = ( path: config.path, label: config.label, graphqlSelection: config.path, - defaultValue: optionsWithStringValues.find(x => x.value === stringifiedDefault) ?? null, + defaultValue: { + kind: 'create', + value: optionsWithStringValues.find(x => x.value === stringifiedDefault) ?? null, + }, type: config.fieldMeta.type, displayMode: config.fieldMeta.displayMode, + isRequired: config.fieldMeta.isRequired, options: optionsWithStringValues, deserialize: data => { for (const option of config.fieldMeta.options) { if (option.value === data[config.path]) { + const stringifiedOption = { label: option.label, value: option.value.toString() }; return { - label: option.label, - value: option.value.toString(), + kind: 'update', + initial: stringifiedOption, + value: stringifiedOption, }; } } - return null; + return { kind: 'update', initial: null, value: null }; }, - serialize: value => ({ [config.path]: t(value?.value ?? null) }), + serialize: value => ({ [config.path]: t(value.value?.value ?? null) }), + validate: value => validate(value, config.fieldMeta.isRequired), filter: { Filter(props) { return ( From 3d7563097dfb5368ba99c5c52f5c5f3afcebff31 Mon Sep 17 00:00:00 2001 From: mitchellhamilton Date: Thu, 23 Sep 2021 15:00:01 +1000 Subject: [PATCH 07/10] WIP --- .../keystone/src/fields/types/select/index.ts | 53 ++++++++--- .../select/tests/non-null/test-fixtures.ts | 92 +++++++++++++++++++ 2 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 packages/keystone/src/fields/types/select/tests/non-null/test-fixtures.ts diff --git a/packages/keystone/src/fields/types/select/index.ts b/packages/keystone/src/fields/types/select/index.ts index fbd8019524a..e5689dc6883 100644 --- a/packages/keystone/src/fields/types/select/index.ts +++ b/packages/keystone/src/fields/types/select/index.ts @@ -9,7 +9,7 @@ import { graphql, filters, } from '../../../types'; -// @ts-ignore +import { assertCreateIsNonNullAllowed, assertReadIsNonNullAllowed } from '../../non-null-graphql'; import { resolveView } from '../../resolve-view'; export type SelectFieldConfig = @@ -36,10 +36,7 @@ export type SelectFieldConfig): FieldTypeFunc => meta => { const fieldLabel = config.label ?? humanize(meta.fieldKey); + if (config.isNullable === false) { + assertReadIsNonNullAllowed(meta, config); + } + assertCreateIsNonNullAllowed(meta, config); const commonConfig = ( options: { value: string | number; label: string }[] ): CommonFieldConfig & { @@ -121,6 +137,19 @@ export const select = return val; }; + const output = (type: T) => + config.isNullable === false && config.graphql?.read?.isNonNull === true + ? graphql.nonNull(type) + : type; + + const create = (type: T) => { + const isNonNull = config.isNullable === false && config.graphql?.read?.isNonNull === true; + return graphql.arg({ + type: isNonNull ? graphql.nonNull(type) : type, + defaultValue: isNonNull ? defaultValue : undefined, + }); + }; + if (config.type === 'integer') { if ( config.options.some( @@ -142,11 +171,11 @@ export const select = arg: graphql.arg({ type: filters[meta.provider].Int[mode] }), resolve: mode === 'required' ? undefined : filters.resolveCommon, }, - create: { arg: graphql.arg({ type: graphql.Int }), resolve: resolveCreate }, + create: { arg: create(graphql.Int), resolve: resolveCreate }, update: { arg: graphql.arg({ type: graphql.Int }) }, orderBy: { arg: graphql.arg({ type: orderDirectionEnum }) }, }, - output: graphql.field({ type: graphql.Int }), + output: graphql.field({ type: output(graphql.Int) }), }); } const options = config.options.map(option => { @@ -181,11 +210,11 @@ export const select = arg: graphql.arg({ type: filters[meta.provider].enum(graphQLType).optional }), resolve: mode === 'required' ? undefined : filters.resolveCommon, }, - create: { arg: graphql.arg({ type: graphQLType }), resolve: resolveCreate }, + create: { arg: create(graphQLType), resolve: resolveCreate }, update: { arg: graphql.arg({ type: graphQLType }) }, orderBy: { arg: graphql.arg({ type: orderDirectionEnum }) }, }, - output: graphql.field({ type: graphQLType }), + output: graphql.field({ type: output(graphQLType) }), }); } return fieldType({ kind: 'scalar', scalar: 'String', ...commonDbFieldConfig })({ @@ -195,10 +224,10 @@ export const select = arg: graphql.arg({ type: filters[meta.provider].String[mode] }), resolve: mode === 'required' ? undefined : filters.resolveString, }, - create: { arg: graphql.arg({ type: graphql.String }), resolve: resolveCreate }, + create: { arg: create(graphql.String), resolve: resolveCreate }, update: { arg: graphql.arg({ type: graphql.String }) }, orderBy: { arg: graphql.arg({ type: orderDirectionEnum }) }, }, - output: graphql.field({ type: graphql.String }), + output: graphql.field({ type: output(graphql.String) }), }); }; diff --git a/packages/keystone/src/fields/types/select/tests/non-null/test-fixtures.ts b/packages/keystone/src/fields/types/select/tests/non-null/test-fixtures.ts new file mode 100644 index 00000000000..4f3edb5bcd0 --- /dev/null +++ b/packages/keystone/src/fields/types/select/tests/non-null/test-fixtures.ts @@ -0,0 +1,92 @@ +import { select } from '../..'; +import { fieldConfig } from '../test-fixtures'; + +export { + exampleValue, + exampleValue2, + supportsUnique, + fieldConfig, + fieldName, +} from '../test-fixtures'; + +type MatrixValue = typeof testMatrix[number]; + +export const name = 'Select with isNullable: false'; +export const typeFunction = (config: any) => select({ ...config, isNullable: false }); + +export const supportedFilters = () => ['equality', 'in_equal']; + +export const testMatrix = ['enum', 'string', 'integer'] as const; + +export const getTestFields = (matrixValue: MatrixValue) => ({ + company: typeFunction(fieldConfig(matrixValue)), +}); + +export const initItems = (matrixValue: MatrixValue) => { + if (matrixValue === 'enum') { + return [ + { name: 'a', company: 'thinkmill' }, + { name: 'b', company: 'atlassian' }, + { name: 'c', company: 'gelato' }, + { name: 'd', company: 'cete' }, + { name: 'e', company: 'react' }, + { name: 'f', company: 'react' }, + { name: 'g', company: 'react' }, + ]; + } else if (matrixValue === 'string') { + return [ + { name: 'a', company: 'a string' }, + { name: 'b', company: '@¯\\_(ツ)_/¯' }, + { name: 'c', company: 'a string' }, + { name: 'd', company: '1number' }, + { name: 'e', company: 'something else' }, + { name: 'f', company: 'something else' }, + { name: 'g', company: 'something else' }, + ]; + } else if (matrixValue === 'integer') { + return [ + { name: 'a', company: 1 }, + { name: 'b', company: 2 }, + { name: 'c', company: 3 }, + { name: 'd', company: 4 }, + { name: 'e', company: 5 }, + { name: 'f', company: 5 }, + { name: 'g', company: 5 }, + ]; + } + return []; +}; + +export const storedValues = (matrixValue: MatrixValue) => { + if (matrixValue === 'enum') { + return [ + { name: 'a', company: 'thinkmill' }, + { name: 'b', company: 'atlassian' }, + { name: 'c', company: 'gelato' }, + { name: 'd', company: 'cete' }, + { name: 'e', company: 'react' }, + { name: 'f', company: 'react' }, + { name: 'g', company: 'react' }, + ]; + } else if (matrixValue === 'string') { + return [ + { name: 'a', company: 'a string' }, + { name: 'b', company: '@¯\\_(ツ)_/¯' }, + { name: 'c', company: 'a string' }, + { name: 'd', company: '1number' }, + { name: 'e', company: 'something else' }, + { name: 'f', company: 'something else' }, + { name: 'g', company: 'something else' }, + ]; + } else if (matrixValue === 'integer') { + return [ + { name: 'a', company: 1 }, + { name: 'b', company: 2 }, + { name: 'c', company: 3 }, + { name: 'd', company: 4 }, + { name: 'e', company: 5 }, + { name: 'f', company: 5 }, + { name: 'g', company: 5 }, + ]; + } +}; From 405683469176db208d0c69178313e5663509cf1a Mon Sep 17 00:00:00 2001 From: mitchellhamilton Date: Thu, 23 Sep 2021 15:12:54 +1000 Subject: [PATCH 08/10] Things --- .changeset/angry-clouds-laugh.md | 5 +++++ .changeset/silver-wolves-tap.md | 5 +++++ .../website/pages/components/fields.tsx | 10 +++++++++- examples-staging/assets-cloud/schema.ts | 2 +- examples-staging/assets-local/schema.ts | 2 +- examples/blog/schema.ts | 2 +- examples/custom-admin-ui-logo/schema.ts | 2 +- examples/custom-admin-ui-navigation/schema.ts | 2 +- examples/custom-admin-ui-pages/schema.ts | 2 +- examples/custom-field-view/schema.ts | 2 +- examples/custom-field/schema.ts | 2 +- examples/default-values/schema.ts | 20 +++++++++++-------- examples/document-field/schema.ts | 2 +- examples/extend-graphql-schema/schema.ts | 2 +- examples/task-manager/schema.ts | 2 +- examples/testing/schema.ts | 2 +- examples/virtual-field/schema.ts | 2 +- examples/with-auth/schema.ts | 2 +- .../select/tests/non-null/test-fixtures.ts | 3 ++- .../types/select/tests/test-fixtures.ts | 1 + tests/test-projects/basic/schema.ts | 2 +- .../crud-notifications/schema.ts | 2 +- 22 files changed, 50 insertions(+), 26 deletions(-) create mode 100644 .changeset/angry-clouds-laugh.md create mode 100644 .changeset/silver-wolves-tap.md diff --git a/.changeset/angry-clouds-laugh.md b/.changeset/angry-clouds-laugh.md new file mode 100644 index 00000000000..2356cd29ee5 --- /dev/null +++ b/.changeset/angry-clouds-laugh.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/keystone': major +--- + +In the `select` field, `defaultValue` is now a static value, `isRequired` has moved to `validation.isRequired`,. The `select` field can also be made non-nullable at the database-level with the `isNullable` option which defaults to `true`. `graphql.read.isNonNull` can also be set if the field has `isNullable: false` and you have no read access control and you don't intend to add any in the future, it will make the GraphQL output field non-nullable. `graphql.create.isNonNull` can also be set if you have no create access control and you don't intend to add any in the future, it will make the GraphQL create input field non-nullable. The `select` can now also be cleared in the Admin UI when `ui.displayMode` is `segmented-control`. diff --git a/.changeset/silver-wolves-tap.md b/.changeset/silver-wolves-tap.md new file mode 100644 index 00000000000..4b37a3bc349 --- /dev/null +++ b/.changeset/silver-wolves-tap.md @@ -0,0 +1,5 @@ +--- +'@keystone-ui/segmented-control': major +--- + +Removed uncontrolled input behaviour diff --git a/design-system/website/pages/components/fields.tsx b/design-system/website/pages/components/fields.tsx index eb3674ea35b..07da4cc5a0f 100644 --- a/design-system/website/pages/components/fields.tsx +++ b/design-system/website/pages/components/fields.tsx @@ -53,6 +53,7 @@ const BasicDatePicker = () => { export default function FieldsPage() { const { spacing } = useTheme(); const [selectVal, setSelectVal] = useState<{ label: string; value: string } | null>(null); + const [segmentedControlVal, setSegmentedControlVal] = useState(undefined); return (

Form Fields

@@ -110,7 +111,14 @@ export default function FieldsPage() {

Segmented Controls

- + { + setSegmentedControlVal(val); + }} + />

Checkboxes

diff --git a/examples-staging/assets-cloud/schema.ts b/examples-staging/assets-cloud/schema.ts index a812e4cf49b..d6a054bf1e2 100644 --- a/examples-staging/assets-cloud/schema.ts +++ b/examples-staging/assets-cloud/schema.ts @@ -6,7 +6,7 @@ export const lists = { fields: { title: text({ isRequired: true }), status: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Draft', value: 'draft' }, { label: 'Published', value: 'published' }, diff --git a/examples-staging/assets-local/schema.ts b/examples-staging/assets-local/schema.ts index 41cd681b398..d61a171eaff 100644 --- a/examples-staging/assets-local/schema.ts +++ b/examples-staging/assets-local/schema.ts @@ -6,7 +6,7 @@ export const lists = { fields: { title: text({ isRequired: true }), status: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Draft', value: 'draft' }, { label: 'Published', value: 'published' }, diff --git a/examples/blog/schema.ts b/examples/blog/schema.ts index f17487891b5..74b286e629e 100644 --- a/examples/blog/schema.ts +++ b/examples/blog/schema.ts @@ -6,7 +6,7 @@ export const lists = { fields: { title: text({ isRequired: true, isFilterable: true }), status: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Draft', value: 'draft' }, { label: 'Published', value: 'published' }, diff --git a/examples/custom-admin-ui-logo/schema.ts b/examples/custom-admin-ui-logo/schema.ts index 4f58914a068..b10f82a13e2 100644 --- a/examples/custom-admin-ui-logo/schema.ts +++ b/examples/custom-admin-ui-logo/schema.ts @@ -7,7 +7,7 @@ export const lists = { fields: { label: text({ isRequired: true }), priority: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Low', value: 'low' }, { label: 'Medium', value: 'medium' }, diff --git a/examples/custom-admin-ui-navigation/schema.ts b/examples/custom-admin-ui-navigation/schema.ts index 4f58914a068..b10f82a13e2 100644 --- a/examples/custom-admin-ui-navigation/schema.ts +++ b/examples/custom-admin-ui-navigation/schema.ts @@ -7,7 +7,7 @@ export const lists = { fields: { label: text({ isRequired: true }), priority: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Low', value: 'low' }, { label: 'Medium', value: 'medium' }, diff --git a/examples/custom-admin-ui-pages/schema.ts b/examples/custom-admin-ui-pages/schema.ts index 4f58914a068..b10f82a13e2 100644 --- a/examples/custom-admin-ui-pages/schema.ts +++ b/examples/custom-admin-ui-pages/schema.ts @@ -7,7 +7,7 @@ export const lists = { fields: { label: text({ isRequired: true }), priority: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Low', value: 'low' }, { label: 'Medium', value: 'medium' }, diff --git a/examples/custom-field-view/schema.ts b/examples/custom-field-view/schema.ts index fd8dc227ba4..7240c3f2874 100644 --- a/examples/custom-field-view/schema.ts +++ b/examples/custom-field-view/schema.ts @@ -7,7 +7,7 @@ export const lists = { fields: { label: text({ isRequired: true }), priority: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Low', value: 'low' }, { label: 'Medium', value: 'medium' }, diff --git a/examples/custom-field/schema.ts b/examples/custom-field/schema.ts index c8d35f59045..701a7d325ba 100644 --- a/examples/custom-field/schema.ts +++ b/examples/custom-field/schema.ts @@ -7,7 +7,7 @@ export const lists = { fields: { title: text({ isRequired: true }), status: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Draft', value: 'draft' }, { label: 'Published', value: 'published' }, diff --git a/examples/default-values/schema.ts b/examples/default-values/schema.ts index 7af374cce1b..656b43b280b 100644 --- a/examples/default-values/schema.ts +++ b/examples/default-values/schema.ts @@ -7,19 +7,23 @@ export const lists = { fields: { label: text({ isRequired: true }), priority: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Low', value: 'low' }, { label: 'Medium', value: 'medium' }, { label: 'High', value: 'high' }, ], - // Dynamic default: Use the label field to determine the priority - defaultValue: ({ originalInput }) => { - if (originalInput.label && originalInput.label.toLowerCase().includes('urgent')) { - return 'high'; - } else { - return 'low'; - } + hooks: { + resolveInput({ resolvedData, originalInput }) { + if (originalInput.priority === undefined) { + if (originalInput.label && originalInput.label.toLowerCase().includes('urgent')) { + return 'high'; + } else { + return 'low'; + } + } + return resolvedData.priority; + }, }, }), // Static default: When a task is first created, it is incomplete diff --git a/examples/document-field/schema.ts b/examples/document-field/schema.ts index bf7810e03fa..888646c5f23 100644 --- a/examples/document-field/schema.ts +++ b/examples/document-field/schema.ts @@ -8,7 +8,7 @@ export const lists = { title: text({ isRequired: true }), slug: text({ isRequired: true, isIndexed: 'unique' }), status: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Draft', value: 'draft' }, { label: 'Published', value: 'published' }, diff --git a/examples/extend-graphql-schema/schema.ts b/examples/extend-graphql-schema/schema.ts index bfd97c00f04..03c30443357 100644 --- a/examples/extend-graphql-schema/schema.ts +++ b/examples/extend-graphql-schema/schema.ts @@ -6,7 +6,7 @@ export const lists = { fields: { title: text({ isRequired: true }), status: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Draft', value: 'draft' }, { label: 'Published', value: 'published' }, diff --git a/examples/task-manager/schema.ts b/examples/task-manager/schema.ts index 4f58914a068..b10f82a13e2 100644 --- a/examples/task-manager/schema.ts +++ b/examples/task-manager/schema.ts @@ -7,7 +7,7 @@ export const lists = { fields: { label: text({ isRequired: true }), priority: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Low', value: 'low' }, { label: 'Medium', value: 'medium' }, diff --git a/examples/testing/schema.ts b/examples/testing/schema.ts index e716f6bfdb8..f546334760d 100644 --- a/examples/testing/schema.ts +++ b/examples/testing/schema.ts @@ -7,7 +7,7 @@ export const lists = { fields: { label: text({ isRequired: true }), priority: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Low', value: 'low' }, { label: 'Medium', value: 'medium' }, diff --git a/examples/virtual-field/schema.ts b/examples/virtual-field/schema.ts index 820c0931648..b752325948a 100644 --- a/examples/virtual-field/schema.ts +++ b/examples/virtual-field/schema.ts @@ -7,7 +7,7 @@ export const lists = { fields: { title: text({ isRequired: true }), status: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Draft', value: 'draft' }, { label: 'Published', value: 'published' }, diff --git a/examples/with-auth/schema.ts b/examples/with-auth/schema.ts index 4580895bc8c..3873696c91e 100644 --- a/examples/with-auth/schema.ts +++ b/examples/with-auth/schema.ts @@ -7,7 +7,7 @@ export const lists = { fields: { label: text({ isRequired: true }), priority: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Low', value: 'low' }, { label: 'Medium', value: 'medium' }, diff --git a/packages/keystone/src/fields/types/select/tests/non-null/test-fixtures.ts b/packages/keystone/src/fields/types/select/tests/non-null/test-fixtures.ts index 4f3edb5bcd0..8bd443e7d1f 100644 --- a/packages/keystone/src/fields/types/select/tests/non-null/test-fixtures.ts +++ b/packages/keystone/src/fields/types/select/tests/non-null/test-fixtures.ts @@ -7,6 +7,7 @@ export { supportsUnique, fieldConfig, fieldName, + skipRequiredTest, } from '../test-fixtures'; type MatrixValue = typeof testMatrix[number]; @@ -14,7 +15,7 @@ type MatrixValue = typeof testMatrix[number]; export const name = 'Select with isNullable: false'; export const typeFunction = (config: any) => select({ ...config, isNullable: false }); -export const supportedFilters = () => ['equality', 'in_equal']; +export const supportedFilters = () => []; export const testMatrix = ['enum', 'string', 'integer'] as const; diff --git a/packages/keystone/src/fields/types/select/tests/test-fixtures.ts b/packages/keystone/src/fields/types/select/tests/test-fixtures.ts index 3f3f4be6bcb..b5139f45139 100644 --- a/packages/keystone/src/fields/types/select/tests/test-fixtures.ts +++ b/packages/keystone/src/fields/types/select/tests/test-fixtures.ts @@ -9,6 +9,7 @@ export const exampleValue = (matrixValue: MatrixValue) => export const exampleValue2 = (matrixValue: MatrixValue) => matrixValue === 'enum' ? 'atlassian' : matrixValue === 'string' ? '1number' : 2; export const supportsUnique = true; +export const skipRequiredTest = true; export const fieldConfig = (matrixValue: MatrixValue) => { if (matrixValue === 'enum' || matrixValue === 'string') { return { diff --git a/tests/test-projects/basic/schema.ts b/tests/test-projects/basic/schema.ts index 1afb7a088ba..b951b909b15 100644 --- a/tests/test-projects/basic/schema.ts +++ b/tests/test-projects/basic/schema.ts @@ -7,7 +7,7 @@ export const lists = { fields: { label: text({ isRequired: true }), priority: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Low', value: 'low' }, { label: 'Medium', value: 'medium' }, diff --git a/tests/test-projects/crud-notifications/schema.ts b/tests/test-projects/crud-notifications/schema.ts index 7174a396e5a..f3552d272cf 100644 --- a/tests/test-projects/crud-notifications/schema.ts +++ b/tests/test-projects/crud-notifications/schema.ts @@ -15,7 +15,7 @@ export const lists = { fields: { label: text({ isRequired: true }), priority: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Low', value: 'low' }, { label: 'Medium', value: 'medium' }, From d2186483b8d1db41085fab169ae26774d46968bd Mon Sep 17 00:00:00 2001 From: mitchellhamilton Date: Thu, 23 Sep 2021 15:22:32 +1000 Subject: [PATCH 09/10] Fix things --- .changeset/angry-clouds-laugh.md | 2 +- examples-staging/ecommerce/schema.prisma | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/angry-clouds-laugh.md b/.changeset/angry-clouds-laugh.md index 2356cd29ee5..5d80b131248 100644 --- a/.changeset/angry-clouds-laugh.md +++ b/.changeset/angry-clouds-laugh.md @@ -2,4 +2,4 @@ '@keystone-next/keystone': major --- -In the `select` field, `defaultValue` is now a static value, `isRequired` has moved to `validation.isRequired`,. The `select` field can also be made non-nullable at the database-level with the `isNullable` option which defaults to `true`. `graphql.read.isNonNull` can also be set if the field has `isNullable: false` and you have no read access control and you don't intend to add any in the future, it will make the GraphQL output field non-nullable. `graphql.create.isNonNull` can also be set if you have no create access control and you don't intend to add any in the future, it will make the GraphQL create input field non-nullable. The `select` can now also be cleared in the Admin UI when `ui.displayMode` is `segmented-control`. +In the `select` field, `dataType` has been renamed to `type`, `defaultValue` is now a static value and `isRequired` has moved to `validation.isRequired`. The `select` field can also be made non-nullable at the database-level with the `isNullable` option which defaults to `true`. `graphql.read.isNonNull` can also be set if the field has `isNullable: false` and you have no read access control and you don't intend to add any in the future, it will make the GraphQL output field non-nullable. `graphql.create.isNonNull` can also be set if you have no create access control and you don't intend to add any in the future, it will make the GraphQL create input field non-nullable. The `select` can now also be cleared in the Admin UI when `ui.displayMode` is `segmented-control`. diff --git a/examples-staging/ecommerce/schema.prisma b/examples-staging/ecommerce/schema.prisma index 4f97ccaaabf..a0616c65df7 100644 --- a/examples-staging/ecommerce/schema.prisma +++ b/examples-staging/ecommerce/schema.prisma @@ -35,7 +35,7 @@ model Product { description String? photo ProductImage? @relation("Product_photo", fields: [photoId], references: [id]) photoId String? @unique @map("photo") - status String? + status String? @default("DRAFT") price Int? user User? @relation("Product_user", fields: [userId], references: [id]) userId String? @map("user") From 3d6a9b9198a57cd5ac11f20e2f5ec367b8d00e86 Mon Sep 17 00:00:00 2001 From: mitchellhamilton Date: Thu, 23 Sep 2021 15:29:27 +1000 Subject: [PATCH 10/10] Docs --- docs/pages/docs/apis/config.mdx | 2 +- docs/pages/docs/apis/fields.mdx | 35 ++++++++++++------- docs/pages/docs/apis/filters.mdx | 6 ++-- docs/pages/updates/new-graphql-api.mdx | 2 +- .../src/fields/types/select/views/index.tsx | 2 +- 5 files changed, 29 insertions(+), 18 deletions(-) diff --git a/docs/pages/docs/apis/config.mdx b/docs/pages/docs/apis/config.mdx index af6bfc8aba1..dd36cf5ecfb 100644 --- a/docs/pages/docs/apis/config.mdx +++ b/docs/pages/docs/apis/config.mdx @@ -125,7 +125,7 @@ The `sqlite` provider is not intended to be used in production systems, and has - `text`: The `text` field type does not support setting a filter as case sensitive or insensitive. Assuming default collation, all the filters except `contains`, `startsWith` and `endsWith` will be case sensitive and `contains`, `startsWith` and `endsWith` will be case insensitive but only for ASCII characters. -- `select`: Using the `dataType: 'enum'` will use a GraphQL `String` type, rather than an `Enum` type. +- `select`: Using the `type: 'enum'`, the value will be represented as a string in the database. ## ui diff --git a/docs/pages/docs/apis/fields.mdx b/docs/pages/docs/apis/fields.mdx index 1d408c2273c..055afb67135 100644 --- a/docs/pages/docs/apis/fields.mdx +++ b/docs/pages/docs/apis/fields.mdx @@ -372,27 +372,37 @@ export default config({ ### select A `select` field represents the selection of one of fixed set of values. -Values can be either strings, integers, or enum values, as determined by the `dataType` option. -This will determine their GraphQL data type, as well as their database storage type. +Values can be either strings, integers, or enum values, as determined by the `type` option. +This will determine their GraphQL data type, as well as their database storage type except for `enum` on SQLite +where the GraphQL type will be an enum but it will be represented as a string in the database. Options: -- `dataType` (default: `'string'`): Sets the type of the values of this field. +- `type` (default: `'string'`): Sets the type of the values of this field. Must be one of `['string', 'enum', 'integer']`. - `options`: An array of `{ label, value }`. `label` is a string to be displayed in the Admin UI. - `value` is either a `string` (for `{ dataType: 'string' }` or `{ dataType: 'enum' }`), or a `number` (for `{ dataType: 'integer' }`). + `value` is either a `string` (for `{ type: 'string' }` or `{ type: 'enum' }`), or a `number` (for `{ type: 'integer' }`). The `value` will be used in the GraphQL API and stored in the database. -- `defaultValue`: (default: `undefined`): Can be either a string/integer value or an async function which takes an argument `({ context, originalInput })` and returns a string/integer value. - This value will be used for the field when creating items if no explicit value is set, and must be one of the values defined in `options`. - `context` is a [`KeystoneContext`](./context) object. - `originalInput` is an object containing the data passed in to the `create` mutation. -- `isRequired` (default: `false`): If `true` then this field can never be set to `null`. +- `isNullable` (default: `true`): If `false` then this field will be made non-nullable in the database and it will never be possible to set as `null`. +- `defaultValue` (default: `false`): This value will be used for the field when creating items if no explicit value is set. +- `validation.isRequired` (default: `false`): If `true` then this field can never be set to `null`. + Unlike `isNullable`, this will require that a value is provided in the Admin UI. + It will also validate this when creating and updating an item through the GraphQL API but it will not enforce it at the database level. + If you would like to enforce it being non-null at the database-level and in the Admin UI, you can set both `isNullable: false` and `validation.isRequired: true`. - `isIndexed` (default: `false`) - If `true` then this field will be indexed by the database. - If `'unique'` then all values of this field must be unique. -- `ui` (default: `{ displayMode: 'select' }`): Configures the display mode of the field in the Admin UI. +- `ui.displayMode` (default: `'select'`): Configures the display mode of the field in the Admin UI. Can be one of `['select', 'segmented-control']`. +- `graphql.read.isNonNull` (default: `false`): If you have no read access control and you don't intend to add any in the future, + you can set this to true and the output field will be non-nullable. This is only allowed when you have no read access control because otherwise, + when access is denied, `null` will be returned which will cause an error since the field is non-nullable and the error + will propagate up until a nullable field is found which means the entire item will be unreadable and when doing an `items` query, all the items will be unreadable. +- `graphql.create.isNonNull` (default: `false`): If you have no create access control and you want to explicitly show that this is field is non-nullable in the create input + you can set this to true and the create field will be non-nullable and have a default value at the GraphQL level. + This is only allowed when you have no create access control because otherwise, the item will always fail access control + if a user doesn't have access to create the particular field regardless of whether or not they specify the field in the create. ```typescript import { config, list } from '@keystone-next/keystone'; @@ -403,13 +413,14 @@ export default config({ ListName: list({ fields: { fieldName: select({ - dataType: 'enum', + type: 'enum', options: [ { label: '...', value: '...' }, /* ... */ ], defaultValue: '...', - isRequired: true, + validation: { isRequired: true, }, + isNullable: false, isIndexed: 'unique', ui: { displayMode: 'select' }, }), diff --git a/docs/pages/docs/apis/filters.mdx b/docs/pages/docs/apis/filters.mdx index 3549a354435..b8aa5dba45e 100644 --- a/docs/pages/docs/apis/filters.mdx +++ b/docs/pages/docs/apis/filters.mdx @@ -59,9 +59,9 @@ The `json` field type does not support filters. ### select -- If the `dataType` is `string`(the default), the same filters as `text` will be available. -- If the `dataType` is `integer`, the same filters as `integer` will be available. -- If the `dataType` is `enum`, the following filters will be available: +- If the `type` is `string`(the default), the same filters as `text` will be available. +- If the `type` is `integer`, the same filters as `integer` will be available. +- If the `type` is `enum`, the following filters will be available: | **Filter name** | **Type** | **Description** | | --------------- | ---------- | ------------------- | | `equals` | `ListKeyFieldKeyType` | Equals | diff --git a/docs/pages/updates/new-graphql-api.mdx b/docs/pages/updates/new-graphql-api.mdx index 9f23435237b..7617f74c4b2 100644 --- a/docs/pages/updates/new-graphql-api.mdx +++ b/docs/pages/updates/new-graphql-api.mdx @@ -19,7 +19,7 @@ export const lists = { fields: { label: text({ isRequired: true }), priority: select({ - dataType: 'enum', + type: 'enum', options: [ { label: 'Low', value: 'low' }, { label: 'Medium', value: 'medium' }, diff --git a/packages/keystone/src/fields/types/select/views/index.tsx b/packages/keystone/src/fields/types/select/views/index.tsx index 9114b7e444f..3a559c5eed3 100644 --- a/packages/keystone/src/fields/types/select/views/index.tsx +++ b/packages/keystone/src/fields/types/select/views/index.tsx @@ -143,7 +143,7 @@ export const controller = ( value: x.value.toString(), })); - // Transform from string value to dataType appropriate value + // Transform from string value to type appropriate value const t = (v: string | null) => v === null ? null : config.fieldMeta.type === 'integer' ? parseInt(v) : v;