From f448a8b3a36b295d4ce5ff9ef2fd7aabcdb5dacc Mon Sep 17 00:00:00 2001 From: Tim Leslie Date: Fri, 26 Mar 2021 08:40:47 +1100 Subject: [PATCH] Remove the unused field types from the fields package (#5234) --- .changeset/witty-cats-notice.md | 5 + packages/fields-mongoid/README.md | 4 - packages/fields/README.md | 7 +- packages/fields/package.json | 9 +- packages/fields/src/index.js | 5 - .../src/types/CalendarDay/Implementation.js | 191 ----- .../types/CalendarDay/Implementation.test.js | 53 -- .../fields/src/types/CalendarDay/README.md | 80 -- .../fields/src/types/CalendarDay/index.js | 16 - .../src/types/CalendarDay/test-fixtures.js | 41 -- packages/fields/src/types/Checkbox/README.md | 2 +- .../src/types/DateTime/Implementation.js | 255 ------- packages/fields/src/types/DateTime/README.md | 97 --- packages/fields/src/types/DateTime/index.js | 16 - .../src/types/DateTime/test-fixtures.js | 116 --- .../fields/src/types/DateTimeUtc/README.md | 2 +- packages/fields/src/types/Decimal/README.md | 3 +- packages/fields/src/types/Float/README.md | 3 +- .../fields/src/types/Slug/Implementation.js | 245 ------- packages/fields/src/types/Slug/README.md | 277 ------- packages/fields/src/types/Slug/index.js | 18 - .../fields/src/types/Slug/test-fixtures.js | 52 -- packages/fields/src/types/Url/README.md | 12 - packages/fields/src/types/Url/index.js | 16 - .../fields/src/types/Url/test-fixtures.js | 43 -- .../fields/src/types/Uuid/Implementation.js | 166 ----- packages/fields/src/types/Uuid/README.md | 103 --- packages/fields/src/types/Uuid/index.js | 54 -- .../fields/src/types/Uuid/test-fixtures.js | 112 --- packages/fields/src/types/Virtual/README.md | 6 +- .../fields/types/CalendarDay.test.ts | 68 -- tests/api-tests/fields/types/DateTime.test.ts | 228 ------ tests/api-tests/fields/types/Slug.test.ts | 694 ------------------ 33 files changed, 16 insertions(+), 2983 deletions(-) create mode 100644 .changeset/witty-cats-notice.md delete mode 100644 packages/fields/src/types/CalendarDay/Implementation.js delete mode 100644 packages/fields/src/types/CalendarDay/Implementation.test.js delete mode 100644 packages/fields/src/types/CalendarDay/README.md delete mode 100644 packages/fields/src/types/CalendarDay/index.js delete mode 100644 packages/fields/src/types/CalendarDay/test-fixtures.js delete mode 100644 packages/fields/src/types/DateTime/Implementation.js delete mode 100644 packages/fields/src/types/DateTime/README.md delete mode 100644 packages/fields/src/types/DateTime/index.js delete mode 100644 packages/fields/src/types/DateTime/test-fixtures.js delete mode 100644 packages/fields/src/types/Slug/Implementation.js delete mode 100644 packages/fields/src/types/Slug/README.md delete mode 100644 packages/fields/src/types/Slug/index.js delete mode 100644 packages/fields/src/types/Slug/test-fixtures.js delete mode 100644 packages/fields/src/types/Url/README.md delete mode 100644 packages/fields/src/types/Url/index.js delete mode 100644 packages/fields/src/types/Url/test-fixtures.js delete mode 100644 packages/fields/src/types/Uuid/Implementation.js delete mode 100644 packages/fields/src/types/Uuid/README.md delete mode 100644 packages/fields/src/types/Uuid/index.js delete mode 100644 packages/fields/src/types/Uuid/test-fixtures.js delete mode 100644 tests/api-tests/fields/types/CalendarDay.test.ts delete mode 100644 tests/api-tests/fields/types/DateTime.test.ts delete mode 100644 tests/api-tests/fields/types/Slug.test.ts diff --git a/.changeset/witty-cats-notice.md b/.changeset/witty-cats-notice.md new file mode 100644 index 00000000000..21cb8c62d37 --- /dev/null +++ b/.changeset/witty-cats-notice.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/fields-legacy': major +--- + +Removed the legacy field types `CalendarDay`, `DateTime`, `Slug`, `Url`, and `Uuid`. diff --git a/packages/fields-mongoid/README.md b/packages/fields-mongoid/README.md index 2f461950e33..91cbc4a99aa 100644 --- a/packages/fields-mongoid/README.md +++ b/packages/fields-mongoid/README.md @@ -102,7 +102,3 @@ However, JavaScript and (depending on your configuration) some DB platforms are in these contexts, the string `'AF3D'` _does not equal_ the string `'af3d'`. For the `MongoId` type, we mitigate this problem by forcing values to lowercase when using the Knex adapter. - -Similar issues are faced by the -[core `Uuid` field type](https://github.com/keystonejs/keystone/tree/master/packages/fields/src/types/Uuid#casing). -It is also often represented using hexadecimal within a string. diff --git a/packages/fields/README.md b/packages/fields/README.md index 25432329b4f..6e73a70aef9 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -10,9 +10,7 @@ Keystone contains a set of primitive fields types that can be imported from the | Field type | Description | | :------------------------------------------------------------------ | :---------------------------------------------------------------------------------------------------------------------------- | -| [`CalendarDay`](/packages/fields/src/types/CalendarDay/README.md) | An abstract "day" value; useful for Birthdays and other all-day events always celebrated in the local time zone | | [`Checkbox`](/packages/fields/src/types/Checkbox/README.md) | A single Boolean value | -| [`DateTime`](/packages/fields/src/types/DateTime/README.md) | A point in time and a time zone offset | | [`DateTimeUtc`](/packages/fields/src/types/DateTimeUtc/README.md) | Represents points in time, stored in UTC | | [`Decimal`](/packages/fields/src/types/Decimal/README.md) | Exact, numeric values in base-10; useful for currency, etc. | | [`File`](/packages/fields/src/types/File/README.md) | Files backed various storage mediums: local filesystem, cloud based hosting, etc. | @@ -21,10 +19,7 @@ Keystone contains a set of primitive fields types that can be imported from the | [`Password`](/packages/fields/src/types/Password/README.md) | A [`bcrypt`](https://en.wikipedia.org/wiki/Bcrypt) hash of the value supplied; | | [`Relationship`](/packages/fields/src/types/Relationship/README.md) | A link between the current list and others, often paired with a field on the other list | | [`Select`](/packages/fields/src/types/Select/README.md) | One of several predefined string values, presented as a dropdown | -| [`Slug`](/packages/fields/src/types/Slug/README.md) | Generate unique slugs (aka. keys, url segments) based on the item's data | | [`Text`](/packages/fields/src/types/Text/README.md) | A basic but versatile text field of arbitrary length | -| [`Url`](/packages/fields/src/types/Url/README.md) | Extends the [`Text`](/packages/fields/src/types/Text/README.md) type to store HTTP URLs | -| [`Uuid`](/packages/fields/src/types/Uuid/README.md) | [Universally Unique Identifiers](https://en.wikipedia.org/wiki/Universally_unique_identifier) (UUIDs); useful for `id` fields | | [`Virtual`](/packages/fields/src/types/Virtual/README.md) | Read-only field with a developer-defined resolver, executed on read | In addition to these, some complex types are packaged separately: @@ -104,7 +99,7 @@ keystone.createList('Post', { fields: { title: { type: Text }, slug: { - type: Slug, + type: Text, adminConfig: { isReadOnly: true, //slug can be created automatically and you may want to show this as read only }, diff --git a/packages/fields/package.json b/packages/fields/package.json index 88f5bca07c6..aa8c2ab09af 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -1,6 +1,6 @@ { "name": "@keystone-next/fields-legacy", - "description": "KeystoneJS Field Types including Text, Password, DateTime, Integer, and more.", + "description": "KeystoneJS Field Types including Text, Password, Integer, and more.", "homepage": "https://github.com/keystonejs/keystone", "repository": "https://github.com/keystonejs/keystone/tree/master/packages/fields", "version": "23.2.0", @@ -12,22 +12,18 @@ "node": ">=10.0.0" }, "dependencies": { - "@apollo/client": "^3.3.12", "@babel/runtime": "^7.13.10", "@keystone-next/access-control-legacy": "^9.0.0", "@keystone-next/adapter-knex-legacy": "^13.2.2", "@keystone-next/adapter-mongoose-legacy": "^11.1.2", "@keystone-next/adapter-prisma-legacy": "^4.0.0", - "@keystone-next/server-side-graphql-client-legacy": "^2.0.1", "@keystone-next/utils-legacy": "^7.0.0", - "@sindresorhus/slugify": "^1.1.0", "apollo-errors": "^1.9.0", "bcryptjs": "^2.4.3", "cuid": "^2.1.8", "date-fns": "^2.19.0", "decimal.js": "^10.2.1", "dumb-passwords": "^0.2.1", - "graphql": "^15.5.0", "inflection": "^1.12.0", "lodash.groupby": "^4.6.0", "luxon": "^1.26.0", @@ -35,9 +31,10 @@ "p-settle": "^4.1.1" }, "devDependencies": { + "@apollo/client": "^3.3.12", "@keystone-next/file-adapters-legacy": "*", + "@keystone-next/server-side-graphql-client-legacy": "*", "@keystone-next/test-utils-legacy": "*", - "globby": "^11.0.3", "graphql-upload": "^11.0.0", "mime": "^2.5.2" } diff --git a/packages/fields/src/index.js b/packages/fields/src/index.js index d9c5af6c3bd..bfcb3132338 100644 --- a/packages/fields/src/index.js +++ b/packages/fields/src/index.js @@ -1,7 +1,5 @@ export { Implementation } from './Implementation'; -export { default as CalendarDay } from './types/CalendarDay'; export { default as Checkbox } from './types/Checkbox'; -export { default as DateTime } from './types/DateTime'; export { default as DateTimeUtc } from './types/DateTimeUtc'; export { default as Decimal } from './types/Decimal'; export { default as File } from './types/File'; @@ -10,8 +8,5 @@ export { default as Integer } from './types/Integer'; export { default as Password } from './types/Password'; export { default as Relationship } from './types/Relationship'; export { default as Select } from './types/Select'; -export { default as Slug } from './types/Slug'; export { default as Text } from './types/Text'; -export { default as Url } from './types/Url'; -export { default as Uuid } from './types/Uuid'; export { default as Virtual } from './types/Virtual'; diff --git a/packages/fields/src/types/CalendarDay/Implementation.js b/packages/fields/src/types/CalendarDay/Implementation.js deleted file mode 100644 index 1f4fac7f806..00000000000 --- a/packages/fields/src/types/CalendarDay/Implementation.js +++ /dev/null @@ -1,191 +0,0 @@ -import { formatISO, parseISO, compareAsc, compareDesc, isValid } from 'date-fns'; -import { MongooseFieldAdapter } from '@keystone-next/adapter-mongoose-legacy'; -import { KnexFieldAdapter } from '@keystone-next/adapter-knex-legacy'; -import { PrismaFieldAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { Implementation } from '../../Implementation'; - -export class CalendarDay extends Implementation { - constructor(path, { format = 'yyyy-MM-dd', dateFrom, dateTo }) { - super(...arguments); - this.format = format; - this._dateFrom = dateFrom; - this._dateTo = dateTo; - - if (this._dateFrom && (this._dateFrom.length !== 10 || !isValid(parseISO(this._dateFrom)))) { - throw new Error( - `Invalid value for option "dateFrom" of field '${this.listKey}.${path}': "${this._dateFrom}"` - ); - } - - if (this._dateTo && (this._dateTo.length !== 10 || !isValid(parseISO(this._dateTo)))) { - throw new Error( - `Invalid value for option "dateTo" of field '${this.listKey}.${path}': "${this._dateFrom}"` - ); - } - - if ( - this._dateTo && - this._dateFrom && - compareAsc(parseISO(this._dateFrom), parseISO(this._dateTo)) === 1 - ) { - throw new Error( - `Invalid values for options "dateFrom", "dateTo" of field '${this.listKey}.${path}': "${dateFrom}" > "${dateTo}"` - ); - } - this.isOrderable = true; - } - - get _supportsUnique() { - return true; - } - - gqlOutputFields() { - return [`${this.path}: String`]; - } - gqlOutputFieldResolvers() { - return { [`${this.path}`]: item => item[this.path] }; - } - gqlQueryInputFields() { - return [ - ...this.equalityInputFields('String'), - ...this.orderingInputFields('String'), - ...this.inInputFields('String'), - ]; - } - - gqlUpdateInputFields() { - return [`${this.path}: String`]; - } - - gqlCreateInputFields() { - return [`${this.path}: String`]; - } - - extendAdminMeta(meta) { - return { - ...meta, - format: this.format, - dateFrom: this._dateFrom, - dateTo: this._dateTo, - }; - } - - async validateInput({ resolvedData, addFieldValidationError }) { - const initialValue = resolvedData[this.path]; - - // Allow passing in the `null` value to the CalendarDay field type - if (initialValue === null) return true; - - const parsedValue = parseISO(resolvedData[this.path]); - - if (!(initialValue.length === 10 && isValid(parsedValue))) { - addFieldValidationError('Invalid CalendarDay value.', { value: resolvedData[this.path] }); - } - if (parsedValue) { - if (parseISO(this._dateFrom) && compareAsc(parseISO(this._dateFrom), parsedValue) === 1) { - addFieldValidationError(`Value is before earliest allowed date: ${this._dateFromString}.`, { - value: resolvedData[this.path], - dateFrom: this._dateFromString, - }); - } - if (parseISO(this._dateTo) && compareDesc(parseISO(this._dateTo), parsedValue) === 1) { - addFieldValidationError(`Value is after latest allowed date: ${this._dateToString}.`, { - value: resolvedData[this.path], - dateTo: this._dateToString, - }); - } - } - } - - getBackingTypes() { - return { [this.path]: { optional: true, type: 'string | null' } }; - } -} - -const CommonCalendarInterface = superclass => - class extends superclass { - getQueryConditions(dbPath) { - return { - ...this.equalityConditions(dbPath), - ...this.orderingConditions(dbPath), - ...this.inConditions(dbPath), - }; - } - }; - -export class MongoCalendarDayInterface extends CommonCalendarInterface(MongooseFieldAdapter) { - addToMongooseSchema(schema) { - const validator = a => typeof a === 'string' && a.length === 10 && parseISO(a); - const schemaOptions = { - type: String, - validate: { - validator: this.buildValidator(validator), - message: '{VALUE} is not an ISO8601 date string (yyyy-MM-dd)', - }, - }; - schema.add({ [this.path]: this.mergeSchemaOptions(schemaOptions, this.config) }); - } -} - -export class KnexCalendarDayInterface extends CommonCalendarInterface(KnexFieldAdapter) { - constructor() { - super(...arguments); - this.isUnique = !!this.config.isUnique; - this.isIndexed = !!this.config.isIndexed && !this.config.isUnique; - } - - addToTableSchema(table) { - const column = table.date(this.path); - if (this.isUnique) column.unique(); - else if (this.isIndexed) column.index(); - if (this.isNotNullable) column.notNullable(); - if (this.defaultTo) column.defaultTo(this.defaultTo); - } - - setupHooks({ addPostReadHook }) { - addPostReadHook(item => { - if (item[this.path]) { - item[this.path] = formatISO(item[this.path], { representation: 'date' }); - } - return item; - }); - } -} - -export class PrismaCalendarDayInterface extends CommonCalendarInterface(PrismaFieldAdapter) { - constructor() { - super(...arguments); - } - - getPrismaSchema() { - return [this._schemaField({ type: 'DateTime' })]; - } - - _stringToDate(s) { - return s && new Date(s + 'T00:00:00+0000'); - } - - getQueryConditions(dbPath) { - return { - ...this.equalityConditions(dbPath, this._stringToDate), - ...this.orderingConditions(dbPath, this._stringToDate), - ...this.inConditions(dbPath, this._stringToDate), - }; - } - - setupHooks({ addPreSaveHook, addPostReadHook }) { - addPreSaveHook(item => { - if (item[this.path]) { - item[this.path] = this._stringToDate(item[this.path]); - } - return item; - }); - - addPostReadHook(item => { - if (item[this.path]) { - item[this.path] = formatISO(item[this.path], { representation: 'date' }); - } - return item; - }); - } -} diff --git a/packages/fields/src/types/CalendarDay/Implementation.test.js b/packages/fields/src/types/CalendarDay/Implementation.test.js deleted file mode 100644 index 156270b56a2..00000000000 --- a/packages/fields/src/types/CalendarDay/Implementation.test.js +++ /dev/null @@ -1,53 +0,0 @@ -import { CalendarDay } from './Implementation'; - -const mocks = { - listAdapter: { newFieldAdapter: () => {} }, - defaultAccess: true, - schemaNames: ['public'], -}; - -describe('CalendarDay#implementation', () => { - it('Instantiates correctly if dateFrom is before dateTo', () => { - expect(() => { - new CalendarDay('date', { dateFrom: '2000-01-01', dateTo: '2001-01-01' }, mocks); - }).not.toThrow(); - }); - - it('Instantiates correctly with only dateFrom', () => { - expect(() => { - new CalendarDay('date', { dateFrom: '2000-01-01' }, mocks); - }).not.toThrow(); - }); - - it('Instantiates correctly with only dateTo', () => { - expect(() => { - new CalendarDay('date', { dateTo: '2000-01-01' }, mocks); - }).not.toThrow(); - }); - - describe('error handling', () => { - it("throws if 'dateTo' is before 'dateFrom'", () => { - return expect( - () => new CalendarDay('date', { dateTo: '2000-01-01', dateFrom: '2020-01-01', mocks }) - ).toThrow(); - }); - - it("throws if 'dateTo' === 'dateFrom'", () => { - return expect( - () => new CalendarDay('date', { dateTo: '2020-01-01', dateFrom: '2020-01-01', mocks }) - ).toThrow(); - }); - - it("throws if 'dateTo' is invalid", () => { - return expect( - () => new CalendarDay('date', { dateTo: '2000--1--1', dateFrom: '2020-01-01', mocks }) - ).toThrow(); - }); - - it("throws if 'dateFrom' is invalid", () => { - return expect( - () => new CalendarDay('date', { dateFrom: '2000--1--1', dateTo: '2020-01-01', mocks }) - ).toThrow(); - }); - }); -}); diff --git a/packages/fields/src/types/CalendarDay/README.md b/packages/fields/src/types/CalendarDay/README.md deleted file mode 100644 index a92568c11ee..00000000000 --- a/packages/fields/src/types/CalendarDay/README.md +++ /dev/null @@ -1,80 +0,0 @@ - - -# CalendarDay - -Stores an abstract "day" value; like a date but _independant of any time zone_. -Useful for Birthdays and other all-day events always celebrated in the local time zone. - -## Usage - -```js -const { Text, Password, CalendarDay } = require('@keystone-next/fields-legacy'); - -keystone.createList('User', { - fields: { - email: { type: Text }, - password: { type: Password }, - lastOnline: { - type: CalendarDay, - dateFrom: '2001-01-16', - dateTo: '2020-05-20', - }, - }, -}); -``` - -### Config - -| Option | Type | Default | Description | -| ------------ | --------- | ----------- | --------------------------------------------------------------- | -| `dateFrom` | `String` | `undefined` | The starting point of the allowable date range. | -| `dateTo` | `String` | `undefined` | The end point of the allowable date range. | -| `isRequired` | `Boolean` | `false` | Does this field require a value? | -| `isUnique` | `Boolean` | `false` | Adds a unique index that allows only unique values to be stored | - -#### `dateFrom` - -The CalendarDay field can enforce selected days to conform to a specific date range. `dateFrom` represents the start of a range and the earliest date that can be selected. `dateFrom` can be provided without a `dateTo` option. However, where a `dateTo` is provided, the `dateFrom` value must be equal to or earlier than the `dateTo` value. - -#### `dateTo` - -The CalendarDay field can enforce selected days to conform to a specific date range. `datTo` represents the end of a range and the latest date that can be selected. `dateTo` can be provided without a `dateFrom` option. However, where a `dateFrom` value is provided, the `dateTo` value must be equal to or after the `dateFrom` value. - -## GraphQL - -`CalendarDay` fields use the `String` type in GraphQL. - -All date values must be in the 10 character ISO8601 format:`YYYY-MM-DD`. - -### Filters - -All filter fields expect values in the ISO8601 (`YYYY-MM-DD`) format. - -| Field name | Type | Description | -| :--------------- | :--------- | :----------------------------------------- | -| `${path}` | `String` | Matching the value provided | -| `${path}_not` | `String` | Not matching the value provided | -| `${path}_in` | `[String]` | Matching any of the values provided | -| `${path}_not_in` | `[String]` | Matching none of the values provided | -| `${path}_lt` | `String` | Before than the value provided | -| `${path}_lte` | `String` | Before or equal to the value provided | -| `${path}_gt` | `String` | More recent than the value provided | -| `${path}_gte` | `String` | More recent or equal to the value provided | - -## Storage - -### Mongoose adapter - -In Mongoose the field is added using the `String` schema type. - -The `isRequired` config option is enforced by KeystoneJS only. - -### Knex adapter - -The Knex adapter uses the [Knex `date` type](https://knexjs.org/#Schema-date): - -The `isRequired` config option is enforced by KeystoneJS and, if equal to `true`, the column is set as not nullable. diff --git a/packages/fields/src/types/CalendarDay/index.js b/packages/fields/src/types/CalendarDay/index.js deleted file mode 100644 index 8c6af5aeb01..00000000000 --- a/packages/fields/src/types/CalendarDay/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import { - CalendarDay, - MongoCalendarDayInterface, - KnexCalendarDayInterface, - PrismaCalendarDayInterface, -} from './Implementation'; - -export default { - type: 'CalendarDay', - implementation: CalendarDay, - adapters: { - mongoose: MongoCalendarDayInterface, - knex: KnexCalendarDayInterface, - prisma: PrismaCalendarDayInterface, - }, -}; diff --git a/packages/fields/src/types/CalendarDay/test-fixtures.js b/packages/fields/src/types/CalendarDay/test-fixtures.js deleted file mode 100644 index a7eddfb12e5..00000000000 --- a/packages/fields/src/types/CalendarDay/test-fixtures.js +++ /dev/null @@ -1,41 +0,0 @@ -import Text from '../Text'; -import CalendarDay from './'; - -export const name = 'CalendarDay'; -export const type = CalendarDay; -export const exampleValue = () => '1990-12-31'; -export const exampleValue2 = () => '2000-12-31'; -export const supportsUnique = true; -export const fieldName = 'testField'; - -export const getTestFields = () => ({ name: { type: Text }, testField: { type } }); - -export const initItems = () => { - return [ - { name: 'person1', testField: '1666-04-12' }, - { name: 'person2', testField: '1950-10-01' }, - { name: 'person3', testField: '1990-12-31' }, - { name: 'person4', testField: '2000-01-20' }, - { name: 'person5', testField: '2020-06-10' }, - { name: 'person6', testField: null }, - { name: 'person7' }, - ]; -}; - -export const storedValues = () => [ - { name: 'person1', testField: '1666-04-12' }, - { name: 'person2', testField: '1950-10-01' }, - { name: 'person3', testField: '1990-12-31' }, - { name: 'person4', testField: '2000-01-20' }, - { name: 'person5', testField: '2020-06-10' }, - { name: 'person6', testField: null }, - { name: 'person7', testField: null }, -]; - -export const supportedFilters = () => [ - 'null_equality', - 'equality', - 'ordering', - 'in_empty_null', - 'in_equal', -]; diff --git a/packages/fields/src/types/Checkbox/README.md b/packages/fields/src/types/Checkbox/README.md index d6dcf169a5a..9ebdd5df0da 100644 --- a/packages/fields/src/types/Checkbox/README.md +++ b/packages/fields/src/types/Checkbox/README.md @@ -31,7 +31,7 @@ The `Checkbox` field type doesn't support indexes or unique enforcement. ## GraphQL -`Uuid` fields use the `Boolean` type in GraphQL. +`Checkbox` fields use the `Boolean` type in GraphQL. ### Input fields diff --git a/packages/fields/src/types/DateTime/Implementation.js b/packages/fields/src/types/DateTime/Implementation.js deleted file mode 100644 index cbaf65a932c..00000000000 --- a/packages/fields/src/types/DateTime/Implementation.js +++ /dev/null @@ -1,255 +0,0 @@ -import { GraphQLScalarType } from 'graphql'; -import { Kind } from 'graphql/language'; -import { DateTime, FixedOffsetZone } from 'luxon'; -import { MongooseFieldAdapter } from '@keystone-next/adapter-mongoose-legacy'; -import { KnexFieldAdapter } from '@keystone-next/adapter-knex-legacy'; -import { PrismaFieldAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { Implementation } from '../../Implementation'; - -class _DateTime extends Implementation { - constructor(path, { format, yearRangeFrom, yearRangeTo, yearPickerType }) { - super(...arguments); - this.format = format; - this.yearRangeFrom = yearRangeFrom; - this.yearRangeTo = yearRangeTo; - this.yearPickerType = yearPickerType; - this.isOrderable = true; - } - - get _supportsUnique() { - return true; - } - - gqlOutputFields() { - return [`${this.path}: DateTime`]; - } - gqlOutputFieldResolvers() { - return { [`${this.path}`]: item => item[this.path] }; - } - gqlQueryInputFields() { - return [ - ...this.equalityInputFields('DateTime'), - ...this.orderingInputFields('DateTime'), - ...this.inInputFields('DateTime'), - ]; - } - gqlUpdateInputFields() { - return [`${this.path}: DateTime`]; - } - gqlCreateInputFields() { - return [`${this.path}: DateTime`]; - } - getGqlAuxTypes() { - return [`scalar DateTime`]; - } - extendAdminMeta(meta) { - return { - ...meta, - format: this.format, - yearRangeFrom: this.yearRangeFrom, - yearRangeTo: this.yearRangeTo, - yearPickerType: this.yearPickerType, - }; - } - gqlAuxFieldResolvers() { - return { - DateTime: new GraphQLScalarType({ - name: 'DateTime', - description: 'DateTime custom scalar represents an ISO 8601 datetime string', - parseValue(value) { - return value; // value from the client - }, - serialize(value) { - return value; // value sent to the client - }, - parseLiteral(ast) { - if (ast.kind === Kind.STRING) { - return ast.value; // ast value is always in string format - } - return null; - }, - }), - }; - } - getBackingTypes() { - return { [this.path]: { optional: true, type: 'string | null' } }; - } -} - -const toDate = s => s && DateTime.fromISO(s, { zone: 'utc' }).toJSDate(); - -const CommonDateTimeInterface = superclass => - class extends superclass { - setupHooks({ addPreSaveHook, addPostReadHook }) { - const field_path = this.path; - const utc_field = `${field_path}_utc`; - const offset_field = `${field_path}_offset`; - - // Updates the relevant value in the item provided (by referrence) - addPreSaveHook(item => { - // Only run the hook if the item actually contains the datetime field - // NOTE: Can't use hasOwnProperty here, as the mongoose data object - // returned isn't a POJO - if (!(field_path in item)) { - return item; - } - - const datetimeString = item[field_path]; - - // NOTE: Even though `0` is a valid timestamp (the unix epoch), it's not a valid ISO string, - // so it's ok to check for falseyness here. - if (!datetimeString) { - item[utc_field] = null; - item[offset_field] = null; - delete item[field_path]; // Never store this field - return item; - } - - if (!DateTime.fromISO(datetimeString, { zone: 'utc' }).isValid) { - throw new Error( - 'Validation failed: DateTime must be either `null` or a valid ISO 8601 string' - ); - } - - item[utc_field] = toDate(datetimeString); - item[offset_field] = DateTime.fromISO(datetimeString, { setZone: true }).toFormat('ZZ'); - delete item[field_path]; // Never store this field - - return item; - }); - - addPostReadHook(item => { - // If there's no fields stored in the DB (can happen with MongoDB), then - // don't bother trying to process anything - // NOTE: Can't use hasOwnProperty here, as the mongoose data object - // returned isn't a POJO - if (!(utc_field in item) && !(offset_field in item)) { - return item; - } - - if (!item[utc_field] || !item[offset_field]) { - item[field_path] = null; - return item; - } - - const datetimeString = DateTime.fromJSDate(item[utc_field], { zone: 'utc' }) - .setZone( - new FixedOffsetZone( - DateTime.fromISO(`1234-01-01T00:00:00${item[offset_field]}`, { - setZone: true, - }).offset - ) - ) - .toISO(); - - item[field_path] = datetimeString; - item[utc_field] = undefined; - item[offset_field] = undefined; - - return item; - }); - } - - getQueryConditions(dbPath) { - return { - ...this.equalityConditions(dbPath, toDate), - ...this.orderingConditions(dbPath, toDate), - ...this.inConditions(dbPath, toDate), - }; - } - }; - -export class MongoDateTimeInterface extends CommonDateTimeInterface(MongooseFieldAdapter) { - constructor() { - super(...arguments); - this.utcPath = `${this.path}_utc`; - this.offsetPath = `${this.path}_offset`; - this.realKeys = [this.utcPath, this.offsetPath]; - this.dbPath = this.utcPath; - } - - addToMongooseSchema(schema) { - const { mongooseOptions } = this.config; - schema.add({ - // FIXME: Mongoose needs to know about this field in order for the correct - // attributes to make it through to the pre-hooks. - [this.path]: { type: String, ...mongooseOptions }, - // These are the actual fields we care about storing in the database. - [this.utcPath]: this.mergeSchemaOptions({ type: Date }, this.config), - [this.offsetPath]: { type: String, ...mongooseOptions }, - }); - } - - getMongoFieldName() { - return `${this.path}_utc`; - } -} - -export class KnexDateTimeInterface extends CommonDateTimeInterface(KnexFieldAdapter) { - constructor() { - super(...arguments); - - this.utcPath = `${this.path}_utc`; - this.offsetPath = `${this.path}_offset`; - this.realKeys = [this.utcPath, this.offsetPath]; - this.sortKey = this.utcPath; - this.dbPath = this.utcPath; - - this.isUnique = !!this.config.isUnique; - this.isIndexed = !!this.config.isIndexed && !this.config.isUnique; - } - - addToTableSchema(table) { - // TODO: Should use a single field on PG - // .. although 2 cols is nice for MySQL (no native datetime with tz) - const utcColumn = table.timestamp(this.utcPath, { useTz: false }); - const offsetColumn = table.text(this.offsetPath); - - // Interpret the index options as effecting both elements - if (this.isUnique) table.unique([this.utcPath, this.offsetPath]); - else if (this.isIndexed) table.index([this.utcPath, this.offsetPath]); - - // Interpret not nullable to mean neither field is nullable - if (this.isNotNullable) { - utcColumn.notNullable(); - offsetColumn.notNullable(); - } - - // Allow defaults to be set for both elements of the value by nesting them - // TODO: Add to docs.. - if (this.defaultTo && (this.defaultTo.utc || this.defaultTo.offset)) { - if (this.defaultTo.utc) utcColumn.defaultTo(this.defaultTo.utc); - if (this.defaultTo.offset) offsetColumn.defaultTo(this.defaultTo.offset); - } else if (this.defaultTo) { - utcColumn.defaultTo(this.defaultTo); - } - } -} - -export class PrismaDateTimeInterface extends CommonDateTimeInterface(PrismaFieldAdapter) { - constructor() { - super(...arguments); - if (this.listAdapter.parentAdapter.provider === 'sqlite') { - throw new Error( - `PrismaAdapter provider "sqlite" does not support field type "${this.field.constructor.name}"` - ); - } - this.utcPath = `${this.path}_utc`; - this.offsetPath = `${this.path}_offset`; - this.realKeys = [this.utcPath, this.offsetPath]; - this.sortKey = this.utcPath; - this.dbPath = this.utcPath; - - this.isUnique = !!this.config.isUnique; - this.isIndexed = !!this.config.isIndexed && !this.config.isUnique; - } - - getPrismaSchema() { - return [ - `${this.path}_utc DateTime? ${this.config.isUnique ? '@unique' : ''}`, - `${this.path}_offset String?`, - ]; - } -} - -export { _DateTime as DateTime }; diff --git a/packages/fields/src/types/DateTime/README.md b/packages/fields/src/types/DateTime/README.md deleted file mode 100644 index 28ba0c823a3..00000000000 --- a/packages/fields/src/types/DateTime/README.md +++ /dev/null @@ -1,97 +0,0 @@ - - -# DateTime - -Stores a point in time and a time zone offset. - -## Usage - -```js -const { DateTime } = require('@keystone-next/fields-legacy'); - -keystone.createList('User', { - fields: { - lastOnline: { - type: DateTime, - format: 'dd/MM/yyyy HH:mm O', - yearRangeFrom: 1901, - yearRangeTo: 2018, - yearPickerType: 'auto', - }, - }, -}); -``` - -### Config - -| Option | Type | Default | Description | -| ---------------- | --------- | ---------------------- | --------------------------------------------------------------------------- | -| `format` | `String` | `--` | Defines the format of the string that the component generates | -| `yearRangeFrom` | `String` | The current year - 100 | Defines the starting point of the year range, e.g. `1918` | -| `yearRangeTo` | `String` | The current year | Defines the ending point of the range in the yearSelect field , e.g. `2018` | -| `yearPickerType` | `String` | `auto` | Defines the input type for the year selector | -| `isRequired` | `Boolean` | `false` | Does this field require a value? | -| `isUnique` | `Boolean` | `false` | Adds a unique index that allows only unique values to be stored | - -#### `format` - -Defines the format of the string using unicode tokens. For example, `dd/MM/yyyy HH:mm O`. - -[Documentation of all the available tokens on Unicode website](https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table) - -#### `yearRangeFrom` - -The DateTime component includes an input that allows the user to change the current year from a range of options. -This prop allows the user to set the beginning of that range. - -The default value for this field is 100 years before the current year. - -#### `yearRangeTo` - -The DateTime component includes an input that allows the user to change the current year from a range of options. -This prop allows the user to set the end of that range. - -The default value for this field is the current year. - -#### `yearPickerType` - -The DateTime component includes an input that allows the user to change the current year from a range of options. This prop allows the user to change the type of that input. - -| Option | Description | -| -------- | --------------------------------------------------------------------------------------- | -| `input` | Generates an input that allows the user to type in a value | -| `select` | Generates a dropdown menu that allows the user to select a value from a list | -| `auto` | Will generate a `select` if the range is 50 or less, otherwise will generate an `input` | - -## GraphQL - -The `DateTime` field type adds a custom scalar `DateTime` and uses it for input and output fields. - -## Storage - -### Mongoose adapter - -On the Mongoose adapter the `DateTime` value is stored across three fields: - -| Field name | Schema type | Description | -| ---------------- | ----------- | -------------------------------------------------- | -| `${path}` | `String` | The full timestamp with offset as a ISO8601 string | -| `${path}_utc` | `Date` | The timestamp as a native JS-style epoch | -| `${path}_offset` | `String` | The offset component as string | - -The `isRequired` config option is enforces by KeystoneJS only. - -### Knex adapter - -On the Knex adapter the `DateTime` value is stored across two fields: - -| Column name | Knex type | Description | -| ---------------- | ----------- | ------------------------------ | -| `${path}_utc` | `timestamp` | The timestamp in UTC | -| `${path}_offset` | `text` | The offset component as string | - -The `isRequired` config option is enforced by KeystoneJS. If the value is equal to `true`, the column is set as not nullable. diff --git a/packages/fields/src/types/DateTime/index.js b/packages/fields/src/types/DateTime/index.js deleted file mode 100644 index 98f69f247bb..00000000000 --- a/packages/fields/src/types/DateTime/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import { - DateTime, - MongoDateTimeInterface, - KnexDateTimeInterface, - PrismaDateTimeInterface, -} from './Implementation'; - -export default { - type: 'DateTime', - implementation: DateTime, - adapters: { - mongoose: MongoDateTimeInterface, - knex: KnexDateTimeInterface, - prisma: PrismaDateTimeInterface, - }, -}; diff --git a/packages/fields/src/types/DateTime/test-fixtures.js b/packages/fields/src/types/DateTime/test-fixtures.js deleted file mode 100644 index 1fa4541e368..00000000000 --- a/packages/fields/src/types/DateTime/test-fixtures.js +++ /dev/null @@ -1,116 +0,0 @@ -import { getItems } from '@keystone-next/server-side-graphql-client-legacy'; -import Text from '../Text'; -import DateTime from './'; - -export const name = 'DateTime'; -export const type = DateTime; -export const exampleValue = () => '1990-12-31T12:34:56.789+01:23'; -export const exampleValue2 = () => '2000-01-20T00:08:00.000+10:00'; -export const supportsUnique = true; -export const fieldName = 'lastOnline'; -export const unSupportedAdapterList = ['prisma_sqlite']; - -export const getTestFields = () => ({ name: { type: Text }, lastOnline: { type } }); - -export const initItems = () => { - return [ - { name: 'person1', lastOnline: '1666-04-12T00:08:00.000+10:00' }, - { name: 'person2', lastOnline: '1950-10-01T23:59:59.999-10:00' }, - { name: 'person3', lastOnline: '1990-12-31T12:34:56.789+01:23' }, - { name: 'person4', lastOnline: '2000-01-20T00:08:00.000+10:00' }, - { name: 'person5', lastOnline: '2020-06-10T10:20:30.456+10:00' }, - { name: 'person6', lastOnline: null }, - { name: 'person7' }, - ]; -}; - -export const storedValues = () => [ - { name: 'person1', lastOnline: '1666-04-12T00:08:00.000+10:00' }, - { name: 'person2', lastOnline: '1950-10-01T23:59:59.999-10:00' }, - { name: 'person3', lastOnline: '1990-12-31T12:34:56.789+01:23' }, - { name: 'person4', lastOnline: '2000-01-20T00:08:00.000+10:00' }, - { name: 'person5', lastOnline: '2020-06-10T10:20:30.456+10:00' }, - { name: 'person6', lastOnline: null }, - { name: 'person7', lastOnline: null }, -]; - -export const supportedFilters = () => [ - 'null_equality', - 'equality', - 'ordering', - 'in_empty_null', - 'in_equal', -]; - -export const filterTests = withKeystone => { - const match = async (keystone, where, expected, sortBy = 'name_ASC') => - expect( - await getItems({ - keystone, - listKey: 'Test', - where, - returnFields: 'name lastOnline', - sortBy, - }) - ).toEqual(expected); - - test( - 'Sorting: sortBy: lastOnline_ASC', - withKeystone(({ keystone, adapterName }) => - match( - keystone, - undefined, - adapterName === 'mongoose' - ? [ - { name: 'person7', lastOnline: null }, - { name: 'person6', lastOnline: null }, - { name: 'person1', lastOnline: '1666-04-12T00:08:00.000+10:00' }, - { name: 'person2', lastOnline: '1950-10-01T23:59:59.999-10:00' }, - { name: 'person3', lastOnline: '1990-12-31T12:34:56.789+01:23' }, - { name: 'person4', lastOnline: '2000-01-20T00:08:00.000+10:00' }, - { name: 'person5', lastOnline: '2020-06-10T10:20:30.456+10:00' }, - ] - : [ - { name: 'person1', lastOnline: '1666-04-12T00:08:00.000+10:00' }, - { name: 'person2', lastOnline: '1950-10-01T23:59:59.999-10:00' }, - { name: 'person3', lastOnline: '1990-12-31T12:34:56.789+01:23' }, - { name: 'person4', lastOnline: '2000-01-20T00:08:00.000+10:00' }, - { name: 'person5', lastOnline: '2020-06-10T10:20:30.456+10:00' }, - { name: 'person6', lastOnline: null }, - { name: 'person7', lastOnline: null }, - ], - 'lastOnline_ASC' - ) - ) - ); - - test( - 'Sorting: sortBy: lastOnline_DESC', - withKeystone(({ keystone, adapterName }) => - match( - keystone, - undefined, - adapterName === 'mongoose' - ? [ - { name: 'person5', lastOnline: '2020-06-10T10:20:30.456+10:00' }, - { name: 'person4', lastOnline: '2000-01-20T00:08:00.000+10:00' }, - { name: 'person3', lastOnline: '1990-12-31T12:34:56.789+01:23' }, - { name: 'person2', lastOnline: '1950-10-01T23:59:59.999-10:00' }, - { name: 'person1', lastOnline: '1666-04-12T00:08:00.000+10:00' }, - { name: 'person6', lastOnline: null }, - { name: 'person7', lastOnline: null }, - ] - : [ - { name: 'person7', lastOnline: null }, - { name: 'person6', lastOnline: null }, - { name: 'person5', lastOnline: '2020-06-10T10:20:30.456+10:00' }, - { name: 'person4', lastOnline: '2000-01-20T00:08:00.000+10:00' }, - { name: 'person3', lastOnline: '1990-12-31T12:34:56.789+01:23' }, - { name: 'person2', lastOnline: '1950-10-01T23:59:59.999-10:00' }, - { name: 'person1', lastOnline: '1666-04-12T00:08:00.000+10:00' }, - ], - 'lastOnline_DESC' - ) - ) - ); -}; diff --git a/packages/fields/src/types/DateTimeUtc/README.md b/packages/fields/src/types/DateTimeUtc/README.md index 9bc9476d2a3..82ae78d8bb0 100644 --- a/packages/fields/src/types/DateTimeUtc/README.md +++ b/packages/fields/src/types/DateTimeUtc/README.md @@ -11,7 +11,7 @@ title: DateTimeUtc Accepts only values that include an offset, explicitly or implicitly (as in JS `Date` objects). Produces JS `Date` objects and ISO 8601 strings. -Unlike the `DateTime` field type only the UTC value is stored. +Only the UTC value is stored. ## Usage diff --git a/packages/fields/src/types/Decimal/README.md b/packages/fields/src/types/Decimal/README.md index 7dfa9d5728c..dcce9ce17a6 100644 --- a/packages/fields/src/types/Decimal/README.md +++ b/packages/fields/src/types/Decimal/README.md @@ -15,11 +15,10 @@ See the [Storage section](#storage) for specifics. ## Usage ```js -const { DateTime, Decimal, Text } = require('@keystone-next/fields-legacy'); +const { Decimal, Text } = require('@keystone-next/fields-legacy'); keystone.createList('Payment', { fields: { - timestamp: { type: DateTime }, description: { type: Text }, amount: { type: Decimal }, }, diff --git a/packages/fields/src/types/Float/README.md b/packages/fields/src/types/Float/README.md index cc836c43194..184903bea22 100644 --- a/packages/fields/src/types/Float/README.md +++ b/packages/fields/src/types/Float/README.md @@ -11,11 +11,10 @@ An imprecise numeric value, stored as a floating point. ## Usage ```js -const { Float, DateTime } = require('@keystone-next/fields-legacy'); +const { Float } = require('@keystone-next/fields-legacy'); keystone.createList('SensorReading', { fields: { - loggedAt: { type: DateTime }, temperature: { type: Float }, humidity: { type: Float }, }, diff --git a/packages/fields/src/types/Slug/Implementation.js b/packages/fields/src/types/Slug/Implementation.js deleted file mode 100644 index 9ee30425087..00000000000 --- a/packages/fields/src/types/Slug/Implementation.js +++ /dev/null @@ -1,245 +0,0 @@ -import { getItems } from '@keystone-next/server-side-graphql-client-legacy'; -import slugify from '@sindresorhus/slugify'; -import cuid from 'cuid'; -import { - Text, - MongoTextInterface as MongoSlugInterface, - KnexTextInterface as KnexSlugInterface, - PrismaTextInterface as PrismaSlugInterface, -} from '../Text/Implementation'; - -const MAX_UNIQUE_ATTEMPTS = 100; - -const findFirstNonEmptyStringValue = fields => - Object.values(fields).find(value => typeof value === 'string' && value); - -const generateSlug = valueToSlugify => slugify(valueToSlugify || ''); - -export class SlugImplementation extends Text { - constructor( - path, - { from, generate, makeUnique, alwaysMakeUnique = false, isUnique, regenerateOnUpdate = true }, - { listKey } - ) { - const listAndFieldPath = `${listKey}.${path}`; - - if (typeof regenerateOnUpdate !== 'boolean') { - throw new Error(`The 'regenerateOnUpdate' option on ${listAndFieldPath} must be true/false`); - } - - if (typeof alwaysMakeUnique !== 'boolean') { - throw new Error(`The 'alwaysMakeUnique' option on ${listAndFieldPath} must be true/false`); - } - - if (from && generate) { - throw new Error( - `Only one of 'from' or 'generate' can be supplied as an option to the Slug field on ${listAndFieldPath}.` - ); - } - - let generateFn; - let makeUniqueFn; - - if (from) { - if (typeof from !== 'string') { - if (typeof from === 'function') { - throw new Error( - `A function was specified for the 'from' option on ${listAndFieldPath}, but 'from' exects a string. Did you mean to set the 'generate' option?` - ); - } - throw new Error(`The 'from' option on ${listAndFieldPath} must be a string`); - } - - generateFn = ({ resolvedData, existingItem }) => { - // Look up fields on the list to ensure a valid field was passed - if (!this.getListByKey(this.listKey).fieldsByPath[from]) { - throw new Error( - `The field '${from}' does not exist on the list '${listKey}' as specified in the 'from' option of '${listAndFieldPath}'` - ); - } - // Ensure we generate on a complete object (because `resolvedData` may - // only be partial) - return generateSlug({ ...existingItem, ...resolvedData }[from]); - }; - } else if (!generate) { - // Set a default `generate` method - generateFn = ({ resolvedData, existingItem }) => { - // Ensure we generate on a complete object (because `resolvedData` may - // only be partial) - const { id, name, title, ...fields } = { ...existingItem, ...resolvedData }; - const valueToSlugify = name || title || findFirstNonEmptyStringValue(fields); - if (!valueToSlugify) { - throw new Error( - 'Unable to find a valid field to generate a slug for ${listAndFieldPath}. Please provide a `generate` method.' - ); - } - return generateSlug(valueToSlugify); - }; - } else { - if (typeof generate !== 'function') { - throw new Error( - `The 'generate' option on ${listAndFieldPath} must be a function, but received ${typeof generate}` - ); - } - - // Wrap the provided generator function in an error handler - generateFn = async ({ resolvedData, existingItem }) => { - const slug = await generate({ resolvedData, existingItem }); - if (typeof slug !== 'string') { - throw new Error( - `${listAndFieldPath}'s 'generate' option resolved with a ${typeof slug}, but expected a string.` - ); - } - return slug; - }; - } - - if (typeof makeUnique === 'undefined') { - // Set the default uniqueifying function - makeUniqueFn = ({ slug }) => `${slug}-${cuid.slug()}`; - } else { - if (typeof makeUnique !== 'function') { - throw new Error( - `The 'makeUnique' option on ${listAndFieldPath} must be a function, but received ${typeof makeUnique}` - ); - } - - // Wrap the provided makeUnique function in an error handler - makeUniqueFn = async ({ slug, previousSlug }) => { - const uniqueifiedSlug = await makeUnique({ slug, previousSlug }); - if (typeof uniqueifiedSlug !== 'string') { - throw new Error( - `${listAndFieldPath}'s 'makeUnique' option resolved with a ${typeof uniqueifiedSlug}, but expected a string.` - ); - } - return uniqueifiedSlug; - }; - } - - const isUniqueCalculated = typeof isUnique === 'undefined' ? true : isUnique; - - super( - arguments[0], - { - ...arguments[1], - // Default isUnique to true - isUnique: isUniqueCalculated, - }, - arguments[2] - ); - - this.isUnique = isUniqueCalculated; - this.generateFn = generateFn; - this.makeUnique = makeUniqueFn; - this.regenerateOnUpdate = regenerateOnUpdate; - this.alwaysMakeUnique = alwaysMakeUnique; - this.isOrderable = true; - } - - async resolveInput({ context, resolvedData, existingItem }) { - let slug; - - // A slug has been passed in - if (resolvedData[this.path]) { - // A slug was passed in, so we want to use that. - // NOTE: This can result in slugs changing if doing an update and the - // passed-in slug is not unique: - // 1. Perform a `create` mutation: `createPost(data: { slug: - // "hello-world" }) { slug }`. - // * Result: `{ slug: "hello-world" }` - // 2. Perform a second `create` mutation with the same slug: `createPost(data: { slug: "hello-world" }) { id slug }`. - // * Result (approximately): `{ id: "1", slug: "hello-world-weer84fs" }` - // 3. Perform an update to the second item, with the same slug as the first (again): `updatePost(id: "1", data: { slug: "hello-world" }) { id slug }`. - // * Result (approximately): `{ id: "1", slug: "hello-world-uyi3lh32" }` - // * The slug has changed, even though we passed the same slug in. - // This happens because there is no way to know what the previously - // passed-in slug was, only the most recently _uniquified_ slug (ie; - // `"hello-world-weer84fs"`). - slug = resolvedData[this.path]; - } else { - // During a create - if (!existingItem) { - // We always generate a new one - slug = await this.generateFn({ resolvedData }); - } else { - // During an update - // There used to be a slug set, and we don't want to forcibly regenerate - if (!this.regenerateOnUpdate) { - // So we re-use that existing slug - // Later, we check for uniqueness against other items, while excluding - // this one, ensuring this slug stays stable. - // NOTE: If a slug was not previously set, this _will not_ generate a - // new one. - slug = existingItem[this.path]; - } else { - // Attempt to regenerate the raw slug (before it was passed through - // `makeUnique`) from existing data - const existingNonUniqueSlug = await this.generateFn({ resolvedData: existingItem }); - - // Now generate the new raw slug (it has yet to be passed through - // `makeUnique`) - const newNonUniqueSlug = await this.generateFn({ resolvedData, existingItem }); - - if (existingNonUniqueSlug === newNonUniqueSlug) { - // If they match, we can re-use the existing, unique slug. Note this - // will still pass through uniquification, but because we only check - // uniqueness against _other_ items, and this item already existed, - // we can assume it will not need re-uniquifying, so passing it - // through the logic below is ok. - slug = existingItem[this.path]; - } else { - // If they don't match, we have to assume some data important to the - // slug has changed, so we go with the new value, and let it get - // uniquified later - slug = newNonUniqueSlug; - } - } - } - } - - if (!this.isUnique && !this.alwaysMakeUnique) { - return slug; - } - - const listAndFieldPath = `${this.listKey}.${this.path}`; - - // Repeat until we have a unique slug, or we've tried too many times - let uniqueSlug = slug; - for (let i = 0; i < MAX_UNIQUE_ATTEMPTS; i += 1) { - if (this.alwaysMakeUnique || i > 0) { - uniqueSlug = await this.makeUnique({ slug, previousSlug: uniqueSlug }); - } - - try { - const result = await getItems({ - // Access Control may filter out some results, so we wouldn't be - // retreiving an accurate list of all existing items. Because we add the - // unique constraint to the field, the database will throw an error if - // we miss a match and try to insert anyway. - context: context.sudo(), - listKey: this.listKey, - first: 1, - where: { - [this.path]: uniqueSlug, - // Ensure we ignore the current item when doing an update - ...(existingItem && existingItem.id && { id_not: existingItem.id }), - }, - }); - // If there aren't any matches, this slug can be considered unique - if (result.length === 0) { - return uniqueSlug; - } - } catch (error) { - throw new Error( - `Attempted to generate a unique slug for ${listAndFieldPath}, but failed with an error: ${error.toString()}` - ); - } - } - - throw new Error( - `Attempted to generate a unique slug for ${listAndFieldPath}, but failed after too many attempts. If you've passed a custom 'makeUnique' function, ensure it is working correctly` - ); - } -} - -export { MongoSlugInterface, KnexSlugInterface, PrismaSlugInterface }; diff --git a/packages/fields/src/types/Slug/README.md b/packages/fields/src/types/Slug/README.md deleted file mode 100644 index 69873d84036..00000000000 --- a/packages/fields/src/types/Slug/README.md +++ /dev/null @@ -1,277 +0,0 @@ - - -# Slug - -Generate unique Slugs (aka; keys / url segments) based on the item's data. - -## Usage - -By default, slugs are generated from a `name` or `title` field (in that order) -if they exist. The field can be specified explicitly with the `from` option -(`from: 'username'`), or for more advanced use-cases, a `generate` function -can be provided (`generate: ({ resolvedData, existingItem }) => mySlugFunc(resolvedData.username)`) - -[See the example](#example) below for the result of an example mutation. - -### Using the defaults - -As we have specified a `title` field, its value will be used to generate a -unique slug. - -```javascript -const { Slug, Text } = require('@keystone-next/fields-legacy'); -const { Keystone } = require('@keystone-next/keystone-legacy'); - -const keystone = new Keystone(/* ... */); - -keystone.createList('Post', { - fields: { - title: { type: Text }, - url: { type: Slug }, - }, -}); -``` - -### Specifying a field - -The item's `username` value will be used to generate a unique slug. - -```javascript -const { Slug, Text } = require('@keystone-next/fields-legacy'); -const { Keystone } = require('@keystone-next/keystone-legacy'); - -const keystone = new Keystone(/* ... */); - -keystone.createList('User', { - fields: { - username: { type: Text }, - url: { - type: Slug, - from: 'username', - }, - }, -}); -``` - -### Custom `generate` method - -```javascript -const { Slug, Text, DateTime } = require('@keystone-next/fields-legacy'); -const { Keystone } = require('@keystone-next/keystone-legacy'); -const slugify = require('slugify'); - -const keystone = new Keystone(/* ... */); - -keystone.createList('Post', { - fields: { - title: { type: Text }, - postedAt: { type: DateTime }, - url: { - type: Slug, - generate: ({ resolvedData }) => slugify(resolvedData.title + '-' + resolvedData.postedAt), - }, - }, -}); -``` - -## Slug stability - -The `Slug` field attempts to reuse the same value across updates (ie; -"stability"). This is particularly important when slugs are used for URL -segments to ensure URLs don't change. - -For example, if you create an item with a slug `abc123`, then perform an -`update` mutation without changing any of the data which the slug initial -generation was based on, the slug should stay as `abc123`. - -### Caveats with updates and `makeUnique` - -There is one situation where the `Slug` field cannot guarantee stability; when: - -- The `regenerateOnUpdate` flag is `true`, and -- Performing an `update` mutation, and -- A `slug` value is passed in which is not unique in the list - -For example: - -1. Perform a `create` mutation: `createPost(data: { slug: "hello-world" }) { slug }`. - - - Result: `{ slug: "hello-world" }` - -2. Perform a second `create` mutation with the same slug: `createPost(data: { slug: "hello-world" }) { id slug }`. - - - Result (approximately): `{ id: "1", slug: "hello-world-weer84fs" }` - -3. Perform an update to the second item, with the same slug as the first (again): `updatePost(id: "1", data: { slug: "hello-world" }) { id slug }`. - - Result (approximately): `{ id: "1", slug: "hello-world-uyi3lh32" }` - - The slug has changed, even though we passed the same slug in. This happens - because there is no way to know what the previously passed-in slug was, only - the most recently _uniquified_ slug (ie; `"hello-world-weer84fs"`). - -#### Workarounds - -1. Don't pass in values for the `Slug` field. Instead, create a `generate` - function which does the work for you in a _deterministic_ way. In this - scenario, we are able to compare what would have previously been generated as - the slug to the newly generated slug and re-use the old one if they match. -2. Specify a _deterministic_ `makeUnique` function (the default is to add a - random suffix). This will ensure that when the duplicate slug is detected, it - will re-generate the same "uniqueified" slug each time. This can be done by - adding an incrementing number on each call. - -## Example - -Given the following list config: - -```javascript -keystone.createList('Post', { - fields: { - title: { type: Text }, - url: { - type: Slug, - from: 'title', - }, - }, -}); -``` - -A mutation to create a new item will auto-generate a slug: - -```graphql -mutation { - createPost(data: { title: "Why I ♥ KeystoneJS" }) { - id - title - url - } -} - -# Result: -# { -# createPost: { -# id: "1", -# title: "Why I ♥ KeystoneJS", -# url: "why-i-love-keystonejs" -# } -# } -``` - -Because we've left `isUnique` at its default value (`isUnique: true`), a -subsequently created item with the same `title` will generate a unique slug: - -```graphql -mutation { - createPost(data: { title: "Why I ♥ KeystoneJS" }) { - id - title - url - } -} - -# Result: -# { -# createPost: { -# id: "2", -# title: "Why I ♥ KeystoneJS", -# url: "why-i-love-keystonejs-2108fh3" -# } -# } -``` - -You can also manually override the slug's value: - -```graphql -mutation { - createPost(data: { title: "Why I ♥ KeystoneJS", url: "keystonejs-is-great" }) { - id - title - url - } -} - -# Result: -# { -# createPost: { -# id: "2", -# title: "Why I ♥ KeystoneJS", -# url: "keystonejs-is-great" -# } -# } -``` - -And overwritten slugs will be uniquified for you when `isUnique: true` (with -[one caveat](#caveats-with-updates-and-makeunique)): - -```graphql -mutation { - createPost(data: { title: "Why I ♥ KeystoneJS", url: "keystonejs-is-great" }) { - id - title - url - } -} - -# Result: -# { -# createPost: { -# id: "2", -# title: "Why I ♥ KeystoneJS", -# url: "keystonejs-is-great-f80p5sm" -# } -# } -``` - -## Config - -| Option | Type | Default | Description | -| -------------------- | ------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `from` | `String` | `undefined` | Specify a field whos value will be used to generate a slug. An error will be thrown if the value of the specified field is not of type `string`. NOTE: Only one of the `from` or `generate` options should be set. | -| `generate` | `Function` | Either `name`, `title`, or the first non-id field found | Will be passed `{ resolvedData, existingItem }` as the first parameter from which you can generate a slug. An error will be thrown if the returned value is not of type `string`. NOTE: Only one of the `from` or `generate` options should be set. | -| `makeUnique` | `Function` | Appends a hyphen followed by 7-10 random lowercase alpha-num characters. | If `isUnique === true` and the slug returned from `generate` is not unique, this method will be executed. This method receives a single parameter `{ value, slug, previousSlug, generatedSlug }` where `slug` is the slug to make unique, `previousSlug` is the previous result of calling `makeUnique`, and `generatedSlug` is the original result of calling `generate` / any value passed in the mutation. If the uniqueified string returned is still not unique, this function will be called again with the uniqueified value set to `previousSlug`. | -| `alwaysMakeUnique` | `Boolean` | `false` | Always run the `makeUnique` method, even if the uniquified slug from `generate` does not collide with any existing slugs. The default of only uniquifying on collision can lead to subtle data leak vulnerabilities such as revealing the existence of a slug for a given field value to exist, even if that item in the list is otherwise innaccessible to queries (eg via Access Control). For example; a deactivated user with slug `sam-smith` will cause a collision for new accounts with a `name` of `Sam Smith`, revealing the existence of previously created accounts of that name by checking if the returned `slug` has a unique string appended. | -| `regenerateOnUpdate` | `Boolean` | `true` | If no value is received during an `update` mutation, generate a value as per a `create` mutation. NOTE: If a value is received, it will overwrite this setting. | -| `isUnique` | `Boolean` | `true` | Ensures `makeUnique` is executed if the value is not unique. Adds a unique database index that allows only unique values to be stored. Implies `isIndexed`. | -| `isIndexed` | `Boolean` | `true` | Set a database index on this field. Setting `isUnique` will also set `isIndexed` to `true`. | - -## GraphQL - -`Slug` fields use the `String` type in GraphQL. - -### Input fields - -| Field name | Type | Description | -| ---------- | -------- | -------------------------------------- | -| `${path}` | `String` | A slug, or blank to execute `generate` | - -### Output fields - -| Field name | Type | Description | -| ---------- | -------- | ------------- | -| `${path}` | `String` | A unique slug | - -### Filters - -| Field name | Type | Description | -| --------------------------- | ---------- | ----------------------------------------------------- | -| `${path}` | `String` | Exact match to the String provided | -| `${path}_contains` | `String` | Contains the String provided as a substring | -| `${path}_starts_with` | `String` | Starts with the String provided | -| `${path}_ends_with` | `String` | Ends with the String provided | -| `${path}_in` | `[String]` | In the array of Strings provided | -| `${path}_not` | `String` | Not an exact match to the String provided | -| `${path}_not_contains` | `String` | Does not contain the String provided as a substring | -| `${path}_not_starts_with` | `String` | Does not start with the String provided | -| `${path}_not_ends_with` | `String` | Does not end with the String provided | -| `${path}_not_in` | `[String]` | Not in the array of Strings provided | -| `${path}_i` | `String` | Case insensitive version of `${path}` | -| `${path}_not_i` | `String` | Case insensitive version of `${path}_not` | -| `${path}_contains_i` | `String` | Case insensitive version of `${path}_contains` | -| `${path}_not_contains_i` | `String` | Case insensitive version of `${path}_not_contains` | -| `${path}_starts_with_i` | `String` | Case insensitive version of `${path}_starts_with` | -| `${path}_not_starts_with_i` | `String` | Case insensitive version of `${path}_not_starts_with` | -| `${path}_ends_with_i` | `String` | Case insensitive version of `${path}_ends_with` | -| `${path}_not_ends_with_i` | `String` | Case insensitive version of `${path}_not_ends_with` | diff --git a/packages/fields/src/types/Slug/index.js b/packages/fields/src/types/Slug/index.js deleted file mode 100644 index 0523ce8e699..00000000000 --- a/packages/fields/src/types/Slug/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import { - SlugImplementation, - MongoSlugInterface, - KnexSlugInterface, - PrismaSlugInterface, -} from './Implementation'; - -const Slug = { - type: 'Slug', - implementation: SlugImplementation, - adapters: { - knex: KnexSlugInterface, - mongoose: MongoSlugInterface, - prisma: PrismaSlugInterface, - }, -}; - -export default Slug; diff --git a/packages/fields/src/types/Slug/test-fixtures.js b/packages/fields/src/types/Slug/test-fixtures.js deleted file mode 100644 index c783ce5f7c8..00000000000 --- a/packages/fields/src/types/Slug/test-fixtures.js +++ /dev/null @@ -1,52 +0,0 @@ -import Text from '../Text'; -import Slug from './'; - -export const name = 'Slug'; -export const type = Slug; -export const exampleValue = () => '"foo"'; -export const exampleValue2 = () => '"bar"'; -export const supportsUnique = null; -export const skipRequiredTest = true; -export const skipUpdateTest = true; -export const fieldName = 'testField'; -export const getTestFields = () => ({ - name: { type: Text }, - testField: { - type, - isUnique: false, - generate: ({ resolvedData, existingItem }) => - typeof { ...existingItem, ...resolvedData }.testField === 'string' - ? { ...existingItem, ...resolvedData }.testField - : 'null', - }, -}); - -export const initItems = () => { - return [ - { name: 'a', testField: '' }, - { name: 'b', testField: 'other' }, - { name: 'c', testField: 'FOOBAR' }, - { name: 'd', testField: 'fooBAR' }, - { name: 'e', testField: 'foobar' }, - { name: 'f', testField: null }, - { name: 'g' }, - ]; -}; - -export const storedValues = () => [ - { name: 'a', testField: '' }, - { name: 'b', testField: 'other' }, - { name: 'c', testField: 'FOOBAR' }, - { name: 'd', testField: 'fooBAR' }, - { name: 'e', testField: 'foobar' }, - { name: 'f', testField: 'null' }, - { name: 'g', testField: 'null' }, -]; - -export const supportedFilters = adapterName => [ - 'equality', - adapterName !== 'prisma_sqlite' && 'equality_case_insensitive', - 'in_value', - adapterName !== 'prisma_sqlite' && 'string', - adapterName !== 'prisma_sqlite' && 'string_case_insensitive', -]; diff --git a/packages/fields/src/types/Url/README.md b/packages/fields/src/types/Url/README.md deleted file mode 100644 index a0eab916e85..00000000000 --- a/packages/fields/src/types/Url/README.md +++ /dev/null @@ -1,12 +0,0 @@ - - -# Url - -Extends the `Text` type to store HTTP URLs. -Adds some interface niceties, like links from the Admin UI table view. - -For usage and config, see the [`Text` field type](/packages/fields/src/types/Text/README.md) docs. diff --git a/packages/fields/src/types/Url/index.js b/packages/fields/src/types/Url/index.js deleted file mode 100644 index 8b6ba3d3a62..00000000000 --- a/packages/fields/src/types/Url/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import { - Text, - MongoTextInterface, - KnexTextInterface, - PrismaTextInterface, -} from '../Text/Implementation'; - -export default { - type: 'Url', - implementation: Text, - adapters: { - mongoose: MongoTextInterface, - knex: KnexTextInterface, - prisma: PrismaTextInterface, - }, -}; diff --git a/packages/fields/src/types/Url/test-fixtures.js b/packages/fields/src/types/Url/test-fixtures.js deleted file mode 100644 index e20ece2587c..00000000000 --- a/packages/fields/src/types/Url/test-fixtures.js +++ /dev/null @@ -1,43 +0,0 @@ -import Text from './'; -import Url from './'; - -export const name = 'Url'; -export const type = Url; -export const exampleValue = () => 'https://keystonejs.org'; -export const exampleValue2 = () => 'https://thinkmill.com.au'; -export const supportsUnique = true; -export const fieldName = 'testField'; - -export const getTestFields = () => ({ name: { type: Text }, testField: { type } }); - -export const initItems = () => { - return [ - { name: 'a', testField: '' }, - { name: 'b', testField: 'other' }, - { name: 'c', testField: 'FOOBAR' }, - { name: 'd', testField: 'fooBAR' }, - { name: 'e', testField: 'foobar' }, - { name: 'f', testField: null }, - { name: 'g' }, - ]; -}; - -export const storedValues = () => [ - { name: 'a', testField: '' }, - { name: 'b', testField: 'other' }, - { name: 'c', testField: 'FOOBAR' }, - { name: 'd', testField: 'fooBAR' }, - { name: 'e', testField: 'foobar' }, - { name: 'f', testField: null }, - { name: 'g', testField: null }, -]; - -export const supportedFilters = adapterName => [ - 'null_equality', - 'equality', - adapterName !== 'prisma_sqlite' && 'equality_case_insensitive', - 'in_empty_null', - 'in_value', - adapterName !== 'prisma_sqlite' && 'string', - adapterName !== 'prisma_sqlite' && 'string_case_insensitive', -]; diff --git a/packages/fields/src/types/Uuid/Implementation.js b/packages/fields/src/types/Uuid/Implementation.js deleted file mode 100644 index 9ff6e07f0b6..00000000000 --- a/packages/fields/src/types/Uuid/Implementation.js +++ /dev/null @@ -1,166 +0,0 @@ -import { MongooseFieldAdapter } from '@keystone-next/adapter-mongoose-legacy'; -import { KnexFieldAdapter } from '@keystone-next/adapter-knex-legacy'; -import { PrismaFieldAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { Implementation } from '../../Implementation'; - -export class UuidImplementation extends Implementation { - constructor(path, { caseTo = 'lower' }) { - super(...arguments); - - this.normaliseValue = a => a; - if (caseTo && caseTo.toString().toLowerCase() === 'upper') { - this.normaliseValue = a => a && a.toString().toUpperCase(); - } else if (caseTo && caseTo.toString().toLowerCase() === 'lower') { - this.normaliseValue = a => a && a.toString().toLowerCase(); - } - this.isOrderable = true; - } - - get _supportsUnique() { - return true; - } - - gqlOutputFields() { - return [`${this.path}: ID${this.isPrimaryKey ? '!' : ''}`]; - } - gqlOutputFieldResolvers() { - return { [`${this.path}`]: item => item[this.path] }; - } - gqlQueryInputFields() { - return [...this.equalityInputFields('ID'), ...this.inInputFields('ID')]; - } - gqlUpdateInputFields() { - return [`${this.path}: ID`]; - } - gqlCreateInputFields() { - return [`${this.path}: ID`]; - } - getBackingTypes() { - return { [this.path]: { optional: true, type: 'string | null' } }; - } -} - -const validator = a => - typeof a === 'string' && - /^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$/.test(a); - -// TODO: UUIDs _should_ be stored in Mongo using binary subtype 0x04 but strings are easier; see README.md -export class MongoUuidInterface extends MongooseFieldAdapter { - addToMongooseSchema(schema, mongoose) { - const schemaOptions = { - type: mongoose.Schema.Types.String, - validate: { - validator: this.buildValidator(validator), - message: '{VALUE} is not a valid UUID. Must be 8-4-4-4-12 hex format', - }, - }; - schema.add({ [this.path]: this.mergeSchemaOptions(schemaOptions, this.config) }); - } - - setupHooks({ addPreSaveHook, addPostReadHook }) { - // TODO: Remove the need to dereference the list and field to get the normalise function - addPreSaveHook(item => { - // Only run the hook if the item actually contains the field - // NOTE: Can't use hasOwnProperty here, as the mongoose data object - // returned isn't a POJO - if (!(this.path in item)) { - return item; - } - - if (item[this.path]) { - if (typeof item[this.path] === 'string') { - item[this.path] = this.field.normaliseValue(item[this.path]); - } else { - // Should have been caught by the validator?? - throw `Invalid UUID value given for '${this.path}'`; - } - } else { - item[this.path] = null; - } - - return item; - }); - addPostReadHook(item => { - if (item[this.path]) { - item[this.path] = this.field.normaliseValue(item[this.path]); - } - return item; - }); - } - - getQueryConditions(dbPath) { - return { - ...this.equalityConditions(dbPath, this.field.normaliseValue), - ...this.inConditions(dbPath, this.field.normaliseValue), - }; - } -} - -export class KnexUuidInterface extends KnexFieldAdapter { - constructor() { - super(...arguments); - - // TODO: Warning on invalid config for primary keys? - if (!this.field.isPrimaryKey) { - this.isUnique = !!this.config.isUnique; - this.isIndexed = !!this.config.isIndexed && !this.config.isUnique; - } - } - - addToTableSchema(table) { - const column = table.uuid(this.path); - // Fair to say primary keys are always non-nullable and uniqueness is implied by primary() - if (this.field.isPrimaryKey) { - column.primary().notNullable(); - } else { - if (this.isUnique) column.unique(); - else if (this.isIndexed) column.index(); - if (this.isNotNullable) column.notNullable(); - } - if (this.defaultTo) column.defaultTo(this.defaultTo); - } - - addToForeignTableSchema(table, { path, isUnique, isIndexed, isNotNullable }) { - if (!this.field.isPrimaryKey) { - throw ( - `Can't create foreign key '${path}' on table "${table._tableName}"; ` + - `'${this.path}' on list '${this.field.listKey}' as is not the primary key.` - ); - } - - const column = table.uuid(path); - if (isUnique) column.unique(); - else if (isIndexed) column.index(); - if (isNotNullable) column.notNullable(); - } - - getQueryConditions(dbPath) { - return { - ...this.equalityConditions(dbPath, this.field.normaliseValue), - ...this.inConditions(dbPath, this.field.normaliseValue), - }; - } -} - -export class PrismaUuidInterface extends PrismaFieldAdapter { - constructor() { - super(...arguments); - - // TODO: Warning on invalid config for primary keys? - if (!this.field.isPrimaryKey) { - this.isUnique = !!this.config.isUnique; - this.isIndexed = !!this.config.isIndexed && !this.config.isUnique; - } - } - - getPrismaSchema() { - return [this._schemaField({ type: 'String' })]; - } - - getQueryConditions(dbPath) { - return { - ...this.equalityConditions(dbPath, this.field.normaliseValue), - ...this.inConditions(dbPath, this.field.normaliseValue), - }; - } -} diff --git a/packages/fields/src/types/Uuid/README.md b/packages/fields/src/types/Uuid/README.md deleted file mode 100644 index 712aa8e62bf..00000000000 --- a/packages/fields/src/types/Uuid/README.md +++ /dev/null @@ -1,103 +0,0 @@ - - -# Uuid - -The `Uuid` field type stores Universally Unique Identifiers (UUIDs). -UUIDs are 128-bit numbers but they're often represented in hexadecimal using the format `00000000-0000-0000-0000-000000000000`. -Here we refer to this encoding as the `8-4-4-4-12` hex format. - -The encoding used for storage differs by DB adapter, see the [Storage section](#storage). - -## Usage - -```js -const { Uuid, Text } = require('@keystone-next/fields-legacy'); - -keystone.createList('Products', { - fields: { - name: { type: Text }, - supplierId: { type: Uuid, caseTo: 'upper' }, - }, -}); -``` - -## Config - -| Option | Type | Default | Description | -| :----------- | :-------- | :-------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `caseTo` | `String` | `'lower'` | Force the hex representation of IDs to upper or lower case when being read or written. Valid values: `'lower'`, `'upper'` or `null` for no conversion. Defaults to `'lower'` as per [RFC 4122](https://tools.ietf.org/html/rfc4122). See also: [Casing](#casing). | -| `isRequired` | `Boolean` | `false` | Does this field require a value? | -| `isUnique` | `Boolean` | `false` | Adds a unique index that allows only unique values to be stored | - -## GraphQL - -`Uuid` fields use the `ID` type in GraphQL. - -### Input fields - -| Field name | Type | Description | -| :--------- | :--- | :---------------------------------- | -| `${path}` | `ID` | UUID in the `8-4-4-4-12` hex format | - -### Output fields - -| Field name | Type | Description | -| :--------- | :--- | :---------------------------------- | -| `${path}` | `ID` | UUID in the `8-4-4-4-12` hex format | - -### Filters - -Since `Uuid` fields encode IDs, "text" filters (eg. `contains`, `starts_with`, etc) have been excluded. -Note also that hexadecimal encoding, as used for UUIDs, is case agnostic. -As such, despite the GraphQL `ID` type being encoded as Strings, all `Uuid` filters are effectively case insensitive. -See the the [Casing section](#casing). - -| Field name | Type | Description | -| :--------------- | :----- | :------------------------------------ | -| `${path}` | `ID` | Exact match to the ID provided | -| `${path}_not` | `ID` | Not an exact match to the ID provided | -| `${path}_in` | `[ID]` | In the array of IDs provided | -| `${path}_not_in` | `[ID]` | Not in the array of IDs provided | - -## Storage - -### Mongoose adapter - -When storing UUIDs, Mongo [recommends BSON objects are used](https://docs.mongodb.com/manual/reference/method/UUID/). -The BSON spec indicates subtype `0x04` specifically. -However most tools (including GraphQL) expect IDs to be encoded as strings and, for UUIDs, specifically expect the `8-4-4-4-12` hex format. -Mongoose has no native support for UUIDs and storing them as BSON requires they be manually converted between these formats when being used. -As such this field type does not currently follow the BSON recommendation; instead, the UUID values are stored as Strings. - -This is not ideal (PRs welcome). -In additional to not being inefficiently stored, working with UUIDs as Strings potentially causes problems with casing. -See the [Casing section](#casing). - -### Knex adapter - -The Knex adapter uses the [Knex `uuid` type](https://knexjs.org/#Schema-uuid): - -> **Note:** this uses the built-in uuid type in PostgreSQL, and falling back to a char(36) in other databases. - -The PostgreSQL `uuid` type is a proper binary representation of the value. -UUIDs in the text/hex format are implicitly cast to the `uuid` type when required so inserts, comparisons, etc. work as intended. - -Other databases, such as MySQL do not have a dedicated UUID type. - -## Casing - -Unless you're on Postgres or MS SQL Server, your DB platform probably doesn't have native support for a UUIDs type. -A string type like `varchar(36)` or `String` will be used instead with values being stored is their `8-4-4-4-12` hex format. -This can cause problems with casing. - -Hexadecial itself is case agnostic. -The hex value `AF3D` is identical to the hex value `af3d`; they both encode the same value as `44861` in decimal and `1010111100111101` in binary. -However, in JavaScript, Mongo and (depending on your configuration) some other DB platforms, the _String_ `'AF3D'` does not equal the string `'af3d'`. - -For this field type, we mitigate this problem using the [`caseTo` config option](#config). -This can be used to force the case of your values (to upper or lower case) whenever they're read, written or compared. -This defaults to `'lower'` as per [the UUID spec](https://tools.ietf.org/html/rfc4122). diff --git a/packages/fields/src/types/Uuid/index.js b/packages/fields/src/types/Uuid/index.js deleted file mode 100644 index 1498ca3c356..00000000000 --- a/packages/fields/src/types/Uuid/index.js +++ /dev/null @@ -1,54 +0,0 @@ -import { - UuidImplementation, - MongoUuidInterface, - KnexUuidInterface, - PrismaUuidInterface, -} from './Implementation'; - -const Uuid = { - type: 'Uuid', - implementation: UuidImplementation, - adapters: { - knex: KnexUuidInterface, - mongoose: MongoUuidInterface, - prisma: PrismaUuidInterface, - }, - - primaryKeyDefaults: { - knex: { - getConfig: client => { - if (client === 'postgres') { - return { - type: Uuid, - knexOptions: { defaultTo: knex => knex.raw('gen_random_uuid()') }, - }; - } - throw ( - `The Uuid field type doesn't provide a default primary key field configuration for the ` + - `'${client}' knex client. You'll need to supply your own 'id' field for each list or use a ` + - `different field type for your ids (eg '@keystone-next/fields-auto-increment-legacy').` - ); - }, - }, - prisma: { - getConfig: client => { - throw ( - `The Uuid field type doesn't provide a default primary key field configuration for the ` + - `'${client}' prisma client. You'll need to supply your own 'id' field for each list or use a ` + - `different field type for your ids (eg '@keystone-next/fields-auto-increment-legacy').` - ); - }, - }, - mongoose: { - getConfig: () => { - throw ( - `The Uuid field type doesn't provide a default primary key field configuration for mongoose. ` + - `You'll need to supply your own 'id' field for each list or use a different field type for your ` + - `ids (eg '@keystone-next/fields-mongoid-legacy').` - ); - }, - }, - }, -}; - -export default Uuid; diff --git a/packages/fields/src/types/Uuid/test-fixtures.js b/packages/fields/src/types/Uuid/test-fixtures.js deleted file mode 100644 index e807f7e6eaa..00000000000 --- a/packages/fields/src/types/Uuid/test-fixtures.js +++ /dev/null @@ -1,112 +0,0 @@ -import { getItems } from '@keystone-next/server-side-graphql-client-legacy'; -import Text from '../Text'; -import Uuid from './'; - -export const name = 'Uuid'; -export const type = Uuid; -export const exampleValue = () => '7b36c9fe-274d-45f1-9f5d-8d4595959734'; -export const exampleValue2 = () => 'c0d37cbc-2f01-432c-89e0-405d54fd4cdc'; -export const supportsUnique = true; -export const fieldName = 'otherId'; - -export const getTestFields = () => ({ name: { type: Text }, otherId: { type } }); - -export const initItems = () => { - return [ - { name: 'a', otherId: 'c0d37cbc-2f01-432c-89e0-405d54fd4cdc' }, - { name: 'b', otherId: '01d20b3c-c0fe-4198-beb6-1a013c041805' }, - { name: 'c', otherId: '8452de22-4dfd-4e2a-a6ac-c20ceef0ade4' }, - { name: 'd', otherId: '01d20b3c-c0fe-4198-beb6-1a013c041806' }, - { name: 'e', otherId: '8452de22-4dfd-4e2a-a6ac-c20ceef0ade4' }, - { name: 'f', otherId: null }, - { name: 'g' }, - ]; -}; - -export const storedValues = () => [ - { name: 'a', otherId: 'c0d37cbc-2f01-432c-89e0-405d54fd4cdc' }, - { name: 'b', otherId: '01d20b3c-c0fe-4198-beb6-1a013c041805' }, - { name: 'c', otherId: '8452de22-4dfd-4e2a-a6ac-c20ceef0ade4' }, - { name: 'd', otherId: '01d20b3c-c0fe-4198-beb6-1a013c041806' }, - { name: 'e', otherId: '8452de22-4dfd-4e2a-a6ac-c20ceef0ade4' }, - { name: 'f', otherId: null }, - { name: 'g', otherId: null }, -]; - -export const supportedFilters = () => ['null_equality', 'equality', 'in_empty_null', 'in_value']; - -export const filterTests = withKeystone => { - const match = async (keystone, where, expected) => - expect( - await getItems({ - keystone, - listKey: 'Test', - where, - returnFields: 'name otherId', - sortBy: 'name_ASC', - }) - ).toEqual(expected); - - test( - `Filter: {key} (implicit case-insensitivity)`, - withKeystone(({ keystone }) => - match(keystone, { otherId: 'C0D37CBC-2F01-432C-89E0-405D54FD4CDC' }, [ - { name: 'a', otherId: 'c0d37cbc-2f01-432c-89e0-405d54fd4cdc' }, - ]) - ) - ); - - test( - `Filter: {key}_not (implicit case-insensitivity)`, - withKeystone(({ keystone }) => - match(keystone, { otherId_not: '8452DE22-4DFD-4E2A-A6AC-C20CEEF0ADE4' }, [ - { name: 'a', otherId: 'c0d37cbc-2f01-432c-89e0-405d54fd4cdc' }, - { name: 'b', otherId: '01d20b3c-c0fe-4198-beb6-1a013c041805' }, - { name: 'd', otherId: '01d20b3c-c0fe-4198-beb6-1a013c041806' }, - { name: 'f', otherId: null }, - { name: 'g', otherId: null }, - ]) - ) - ); - - test( - `Filter: {key}_in (implicit case-insensitivity)`, - withKeystone(({ keystone }) => - match( - keystone, - { - otherId_in: [ - '01D20B3C-C0FE-4198-BEB6-1A013C041805', - 'C0D37CBC-2F01-432C-89E0-405D54FD4CDC', - ], - }, - [ - { name: 'a', otherId: 'c0d37cbc-2f01-432c-89e0-405d54fd4cdc' }, - { name: 'b', otherId: '01d20b3c-c0fe-4198-beb6-1a013c041805' }, - ] - ) - ) - ); - - test( - `Filter: {key}_not_in (implicit case-insensitivity)`, - withKeystone(({ keystone }) => - match( - keystone, - { - otherId_not_in: [ - '01D20B3C-C0FE-4198-BEB6-1A013C041805', - 'C0D37CBC-2F01-432C-89E0-405D54FD4CDC', - ], - }, - [ - { name: 'c', otherId: '8452de22-4dfd-4e2a-a6ac-c20ceef0ade4' }, - { name: 'd', otherId: '01d20b3c-c0fe-4198-beb6-1a013c041806' }, - { name: 'e', otherId: '8452de22-4dfd-4e2a-a6ac-c20ceef0ade4' }, - { name: 'f', otherId: null }, - { name: 'g', otherId: null }, - ] - ) - ) - ); -}; diff --git a/packages/fields/src/types/Virtual/README.md b/packages/fields/src/types/Virtual/README.md index 2ccb6b0d9d3..a512ad5ba38 100644 --- a/packages/fields/src/types/Virtual/README.md +++ b/packages/fields/src/types/Virtual/README.md @@ -84,12 +84,12 @@ The GraphQL arguments to a `Virtual` field can be specified using the `args` opt The values for these arguments are made available in the second argument to the resolver function. ```js -const { Virtual, CalendarDay } = require('@keystone-next/fields-legacy'); +const { Virtual, DateTimeUtc } = require('@keystone-next/fields-legacy'); const { format, parseISO } = require('date-fns'); keystone.createList('Example', { fields: { - date: { type: CalendarDay }, + date: { type: DateTimeUtc }, formattedDate: { type: Virtual, resolver: (item, { formatAs = 'do MMMM, yyyy' }) => @@ -106,7 +106,7 @@ The `item` argument to the resolver function is the raw database representation If you need to access data beyond what lives on the `item` you can execute a [server-side GraphQL query](/docs/discussions/server-side-graphql.md) using `context.executeGraphQL()`. ```js -const { Virtual, CalendarDay } = require('@keystone-next/fields-legacy'); +const { Virtual } = require('@keystone-next/fields-legacy'); const { format, parseISO } = require('date-fns'); keystone.createList('Example', { diff --git a/tests/api-tests/fields/types/CalendarDay.test.ts b/tests/api-tests/fields/types/CalendarDay.test.ts deleted file mode 100644 index b315ec32306..00000000000 --- a/tests/api-tests/fields/types/CalendarDay.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { AdapterName, multiAdapterRunners, setupServer } from '@keystone-next/test-utils-legacy'; - -const { CalendarDay } = require('@keystone-next/fields-legacy'); - -function setupKeystone(adapterName: AdapterName) { - return setupServer({ - adapterName, - createLists: keystone => { - keystone.createList('User', { - fields: { - birthday: { type: CalendarDay, dateFrom: '2000-01-01', dateTo: '2020-01-01' }, - }, - }); - }, - }); -} - -multiAdapterRunners().map(({ runner, adapterName }) => - describe(`Adapter: ${adapterName}`, () => { - describe('CalendarDay type', () => { - test( - 'Valid date passes validation', - runner(setupKeystone, async ({ keystone }) => { - const { data, errors } = await keystone.executeGraphQL({ - query: `mutation { createUser(data: { birthday: "2001-01-01" }) { birthday } }`, - }); - expect(errors).toBe(undefined); - expect(data).toHaveProperty('createUser.birthday', '2001-01-01'); - }) - ); - - test( - 'date === dateTo passes validation', - runner(setupKeystone, async ({ keystone }) => { - const { data, errors } = await keystone.executeGraphQL({ - query: `mutation { createUser(data: { birthday: "2020-01-01" }) { birthday } }`, - }); - expect(errors).toBe(undefined); - expect(data).toHaveProperty('createUser.birthday', '2020-01-01'); - }) - ); - - test( - 'date === dateFrom passes validation', - runner(setupKeystone, async ({ keystone }) => { - const { data, errors } = await keystone.executeGraphQL({ - query: `mutation { createUser(data: { birthday: "2020-01-01" }) { birthday } }`, - }); - expect(errors).toBe(undefined); - expect(data).toHaveProperty('createUser.birthday', '2020-01-01'); - }) - ); - - test( - 'Invalid date failsvalidation', - runner(setupKeystone, async ({ keystone }) => { - const { data, errors } = await keystone.executeGraphQL({ - query: `mutation { createUser(data: { birthday: "3000-01-01" }) { birthday } }`, - }); - expect(errors).toHaveLength(1); - const error = errors[0]; - expect(error.message).toEqual('You attempted to perform an invalid mutation'); - expect(data.createUser).toBe(null); - }) - ); - }); - }) -); diff --git a/tests/api-tests/fields/types/DateTime.test.ts b/tests/api-tests/fields/types/DateTime.test.ts deleted file mode 100644 index 2c1ccbcae5e..00000000000 --- a/tests/api-tests/fields/types/DateTime.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { AdapterName, multiAdapterRunners, setupServer } from '@keystone-next/test-utils-legacy'; -// @ts-ignore -import { Text, DateTime } from '@keystone-next/fields-legacy'; -// @ts-ignore -import { createItem } from '@keystone-next/server-side-graphql-client-legacy'; - -function setupKeystone(adapterName: AdapterName) { - return setupServer({ - adapterName, - createLists: keystone => { - keystone.createList('Post', { - fields: { - title: { type: Text }, - postedAt: { type: DateTime }, - }, - }); - }, - }); -} -multiAdapterRunners().map(({ runner, adapterName }) => { - if (adapterName === 'prisma_sqlite') { - // Appease jest, which doesn't like it when you have an empty test file. - test('noop', () => {}); - } else { - describe(`Adapter: ${adapterName}`, () => { - describe('DateTime type', () => { - test( - 'is present in the schema', - runner(setupKeystone, async ({ keystone }) => { - // Introspection query - const { - data: { __schema }, - errors, - } = await keystone.executeGraphQL({ - query: ` - query { - __schema { - types { - name - kind - fields { - name - type { - name - } - } - } - } - } - `, - }); - expect(errors).toBe(undefined); - expect(__schema).toHaveProperty('types'); - expect(__schema.types).toMatchObject( - expect.arrayContaining([ - expect.objectContaining({ - name: 'DateTime', - kind: 'SCALAR', - }), - ]) - ); - - expect(__schema.types).toMatchObject( - expect.arrayContaining([ - expect.objectContaining({ - name: 'Post', - fields: expect.arrayContaining([ - expect.objectContaining({ - name: 'postedAt', - type: { - name: 'DateTime', - }, - }), - ]), - }), - ]) - ); - }) - ); - - test( - 'response is serialized as a String', - runner(setupKeystone, async ({ keystone }) => { - const postedAt = '2018-08-31T06:49:07.000Z'; - - const createPost = await createItem({ - keystone, - listKey: 'Post', - item: { postedAt }, - }); - - // Create an item that does the linking - const { data, errors } = await keystone.executeGraphQL({ - query: ` - query { - Post(where: { id: "${createPost.id}" }) { - postedAt - } - } - `, - }); - expect(errors).toBe(undefined); - expect(data).toHaveProperty('Post.postedAt', postedAt); - }) - ); - - test( - 'input type is accepted as a String', - runner(setupKeystone, async ({ keystone }) => { - const postedAt = '2018-08-31T06:49:07.000Z'; - - // Create an item that does the linking - const { data, errors } = await keystone.executeGraphQL({ - query: ` - mutation { - createPost(data: { postedAt: "${postedAt}" }) { - postedAt - } - } - `, - }); - - expect(errors).toBe(undefined); - expect(data).toHaveProperty('createPost.postedAt', postedAt); - }) - ); - - test( - 'correctly overrides with new value', - runner(setupKeystone, async ({ keystone }) => { - const postedAt = '2018-08-31T06:49:07.000Z'; - const updatedPostedAt = '2018-12-07T05:54:00.556Z'; - - const createPost = await createItem({ - keystone, - listKey: 'Post', - item: { postedAt }, - }); - - // Create an item that does the linking - const { data, errors } = await keystone.executeGraphQL({ - query: ` - mutation { - updatePost(id: "${createPost.id}", data: { postedAt: "${updatedPostedAt}" }) { - postedAt - } - } - `, - }); - expect(errors).toBe(undefined); - expect(data).toHaveProperty('updatePost.postedAt', updatedPostedAt); - }) - ); - - test( - 'allows replacing date with null', - runner(setupKeystone, async ({ keystone }) => { - const postedAt = '2018-08-31T06:49:07.000Z'; - - const createPost = await createItem({ - keystone, - listKey: 'Post', - item: { postedAt }, - }); - - // Create an item that does the linking - const { data, errors } = await keystone.executeGraphQL({ - query: ` - mutation { - updatePost(id: "${createPost.id}", data: { postedAt: null }) { - postedAt - } - } - `, - }); - expect(errors).toBe(undefined); - expect(data).toHaveProperty('updatePost.postedAt', null); - }) - ); - - test( - 'allows initialising to null', - runner(setupKeystone, async ({ keystone }) => { - // Create an item that does the linking - const { data, errors } = await keystone.executeGraphQL({ - query: ` - mutation { - createPost(data: { postedAt: null }) { - postedAt - } - } - `, - }); - expect(errors).toBe(undefined); - expect(data).toHaveProperty('createPost.postedAt', null); - }) - ); - - test( - 'Does not get clobbered when updating unrelated field', - runner(setupKeystone, async ({ keystone }) => { - const postedAt = '2018-08-31T06:49:07.000Z'; - const title = 'Hello world'; - - const createPost = await createItem({ - keystone, - listKey: 'Post', - item: { postedAt, title }, - }); - - // Create an item that does the linking - const { data, errors } = await keystone.executeGraphQL({ - query: ` - mutation { - updatePost(id: "${createPost.id}", data: { title: "Something else" }) { - postedAt - } - } - `, - }); - expect(errors).toBe(undefined); - expect(data).toHaveProperty('updatePost.postedAt', postedAt); - }) - ); - }); - }); - } -}); diff --git a/tests/api-tests/fields/types/Slug.test.ts b/tests/api-tests/fields/types/Slug.test.ts deleted file mode 100644 index df7de94c27d..00000000000 --- a/tests/api-tests/fields/types/Slug.test.ts +++ /dev/null @@ -1,694 +0,0 @@ -import cuid from 'cuid'; -import { - createItem, - deleteItem, - updateItem, - // @ts-ignore -} from '@keystone-next/server-side-graphql-client-legacy'; -import { AdapterName, multiAdapterRunners, setupServer } from '@keystone-next/test-utils-legacy'; -// @ts-ignore -import { Text, Slug } from '@keystone-next/fields-legacy'; - -const reverse = (str: string) => str.split('').reverse().join(''); - -const generateListName = () => - // Ensure we prefix with something easy to delete, but also must always start - // with an upper case alpha character - 'Slugtest' + - // Add randomness - cuid.slug() + - // Ensure plurality isn't a problem - 'foo'; - -const setupList = (adapterName: AdapterName, fields: any) => () => - setupServer({ - adapterName, - createLists: keystone => { - keystone.createList(generateListName(), { fields }); - }, - }); - -describe('Slug#implementation', () => { - multiAdapterRunners().map(({ runner, adapterName }) => - describe(`Adapter: ${adapterName}`, () => { - test('Instantiates correctly if from is a string', () => { - expect(() => { - runner( - setupList(adapterName, { url: { type: Slug, from: 'foo' } }), - // Empty test, we just want to assert the setup works - async () => {} - )(); - }).not.toThrow(); - }); - - test( - "By default, generates a slug from the 'name' field", - runner( - setupList(adapterName, { - name: { type: Text }, - title: { type: Text }, - url: { type: Slug }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Test Entry!', title: 'A title' }; - const returnFields = 'url'; - const result = await createItem({ keystone, listKey, item, returnFields }); - expect(result).toMatchObject({ url: 'test-entry' }); - } - ) - ); - - test( - "By default, generates a slug from the 'title' field if no 'name' field exists", - runner( - setupList(adapterName, { - title: { type: Text }, - url: { type: Slug }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { title: 'Something funny?' }; - const returnFields = 'url'; - const result = await createItem({ keystone, listKey, item, returnFields }); - expect(result).toMatchObject({ url: 'something-funny' }); - } - ) - ); - - test( - "Generates a slug using the 'from' field specified", - runner( - setupList(adapterName, { - username: { type: Text }, - url: { type: Slug, from: 'username' }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { username: 'Test Entry!' }; - const returnFields = 'url'; - const result = await createItem({ keystone, listKey, item, returnFields }); - expect(result).toMatchObject({ url: 'test-entry' }); - } - ) - ); - - test(`Should handle an async 'generate' function`, () => { - const generate = () => new Promise(resolve => setTimeout(() => resolve('foobar'), 4)); - return runner( - setupList(adapterName, { - name: { type: Text }, - url: { type: Slug, generate }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Awesome Sauce' }; - const returnFields = 'url'; - const result = await createItem({ keystone, listKey, item, returnFields }); - expect(result).toMatchObject({ url: 'foobar' }); - } - )(); - }); - - test( - `Should handle an async 'makeUnique' function`, - runner( - setupList(adapterName, { - name: { type: Text }, - url: { - type: Slug, - from: 'name', - makeUnique: () => new Promise(resolve => setTimeout(() => resolve('foobar'), 4)), - }, - }), - async ({ keystone }) => { - const item = { name: 'Awesome Sauce', url: 'awesome-sauce' }; - const { key: listKey } = keystone.listsArray[0]; - await createItem({ keystone, listKey, item }); - const result = await createItem({ keystone, listKey, item, returnFields: 'url' }); - expect(result).toMatchObject({ url: 'foobar' }); - } - ) - ); - - describe('create mutation', () => { - test( - "Doesn't generate when a value is set in mutation", - runner( - setupList(adapterName, { - username: { type: Text }, - url: { type: Slug, from: 'username' }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { username: 'Test Entry!', url: 'foo' }; - const returnFields = 'url'; - const result = await createItem({ keystone, listKey, item, returnFields }); - expect(result).toMatchObject({ url: 'foo' }); - } - ) - ); - - test('Calls generate with `{ resolvedData, existingData }`', () => { - const generate = jest.fn(() => 'foobar'); - return runner( - setupList(adapterName, { - name: { type: Text }, - url: { type: Slug, generate }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Awesome Sauce' }; - await createItem({ keystone, listKey, item }); - expect(generate).toHaveBeenCalledTimes(1); - expect(generate).toHaveBeenCalledWith( - expect.objectContaining({ - resolvedData: expect.any(Object), - existingItem: undefined, - }) - ); - } - )(); - }); - - test( - 'Generates a unique slug when a collision occurs', - runner( - setupList(adapterName, { - name: { type: Text }, - url: { type: Slug, from: 'name' }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Awesome Sauce', url: 'awesome-sauce' }; - const returnFields = 'url'; - await createItem({ keystone, listKey, item }); - const { url } = await createItem({ keystone, listKey, item, returnFields }); - expect(url).toMatch(/^awesome-sauce-[a-zA-Z0-9]+$/); - } - ) - ); - - test( - "Calls 'makeUnique' when a collision occurs", - runner( - setupList(adapterName, { - name: { type: Text }, - url: { - type: Slug, - from: 'name', - makeUnique: ({ slug }: { slug: string }) => reverse(slug), - }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Awesome Sauce', url: 'awesome-sauce' }; - const returnFields = 'url'; - await createItem({ keystone, listKey, item }); - const result = await createItem({ keystone, listKey, item, returnFields }); - expect(result).toMatchObject({ url: reverse('awesome-sauce') }); - } - ) - ); - - test("Doesn't call 'makeUnique' when isUnique: false & a collision occurs", () => { - const makeUnique = jest.fn(); - return runner( - setupList(adapterName, { - name: { type: Text }, - url: { type: Slug, from: 'name', makeUnique, isUnique: false }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Awesome Sauce', url: 'awesome-sauce' }; - const returnFields = 'url'; - await createItem({ keystone, listKey, item, returnFields }); - const result = await createItem({ keystone, listKey, item, returnFields }); - expect(result).toMatchObject({ url: 'awesome-sauce' }); - expect(makeUnique).not.toHaveBeenCalled(); - } - )(); - }); - - test( - 'Generates a unique slug when alwaysMakeUnique: true', - runner( - setupList(adapterName, { - name: { type: Text }, - url: { type: Slug, from: 'name', alwaysMakeUnique: true }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Wicked Sauce', url: 'wicked-sauce' }; - const returnFields = 'url'; - const { url } = await createItem({ keystone, listKey, item, returnFields }); - expect(url).toMatch(/^wicked-sauce-[a-zA-Z0-9]+$/); - } - ) - ); - - test( - "Calls 'makeUnique' when alwaysMakeUnique is true", - runner( - setupList(adapterName, { - name: { type: Text }, - url: { - type: Slug, - from: 'name', - alwaysMakeUnique: true, - makeUnique: ({ slug }: { slug: string }) => reverse(slug), - }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Awesome Sauce', url: 'awesome-sauce' }; - const returnFields = 'url'; - const result = await createItem({ keystone, listKey, item, returnFields }); - expect(result).toMatchObject({ url: reverse('awesome-sauce') }); - } - ) - ); - - test("Still calls 'makeUnique' when isUnique: false & alwaysMakeUnique: true", () => { - const makeUnique = jest.fn(({ slug }) => reverse(slug)); - return runner( - setupList(adapterName, { - name: { type: Text }, - url: { - type: Slug, - from: 'name', - alwaysMakeUnique: true, - makeUnique, - isUnique: false, - }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Awesome Sauce', url: 'awesome-sauce' }; - const returnFields = 'url'; - const result = await createItem({ keystone, listKey, item, returnFields }); - expect(result).toMatchObject({ url: reverse('awesome-sauce') }); - expect(makeUnique).toHaveBeenCalled(); - } - )(); - }); - }); - - describe('update mutation', () => { - it('Calls generate with `{ resolvedData, existingData }` on update', () => { - const generate = jest.fn(() => 'foobar'); - return runner( - setupList(adapterName, { - name: { type: Text }, - url: { type: Slug, generate }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Awesome Sauce', url: 'awesome-sauce' }; - const { id } = await createItem({ keystone, listKey, item }); - - await updateItem({ - keystone, - listKey, - item: { id, data: { name: 'Something Else' } }, - }); - - expect(generate).toHaveBeenCalledWith( - expect.objectContaining({ - resolvedData: expect.any(Object), - existingItem: expect.any(Object), - }) - ); - } - )(); - }); - - test( - 'Has a stable unique ID across regenerations', - // 1. Create { name: "Hi Ho" } (slug: 'hi-ho') - // 2. { id } = Create { name: "Hi Ho" } (slug: 'hi-ho-dsbwerlk') - // 3. Update { id, author: "Sam" } (slug: 'hi-ho-dsbwerlk') - runner( - setupList(adapterName, { - name: { type: Text }, - author: { type: Text }, - url: { type: Slug, from: 'name' }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Hi Ho', url: 'hi-ho' }; - const returnFields = 'id url'; - await createItem({ keystone, listKey, item }); - - const { id, url } = await createItem({ - keystone, - listKey, - item: { name: 'Hi Ho' }, - returnFields, - }); - - // The slug has been uniquified - expect(url).toMatch(/hi-ho-[a-zA-Z0-9]+/); - - const result = await updateItem({ - keystone, - listKey, - item: { id, data: { author: 'Sam' } }, - returnFields, - }); - - // The url should not have changed across updates, even though we - // have regenerateOnUpdate: true - expect(result).toMatchObject({ url }); - } - ) - ); - - // NOTE: This documents current behaviour, but it would be great if in - // the future this test were to be modified so the slug _is_ stable. - test( - 'Does not have a stable unique ID across regenerations when a slug is supplied', - // 1. Create { name: "Hi Ho" } (slug: 'hi-ho') - // 2. { id } = Create { name: "Hi Ho", slug: "hi-ho" } (slug: 'hi-ho-dsbwerlk') - // 3. Update { id, author: "Sam", slug: "hi-ho" } (slug: 'hi-ho-dsbwerlk') - runner( - setupList(adapterName, { - name: { type: Text }, - author: { type: Text }, - url: { type: Slug, from: 'name' }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Hi Ho', url: 'hi-ho' }; - const returnFields = 'id url'; - await createItem({ keystone, listKey, item }); - - const { id, url } = await createItem({ keystone, listKey, item, returnFields }); - - // The slug has been uniquified - expect(url).toMatch(/hi-ho-[a-zA-Z0-9]+/); - - const result = await updateItem({ - keystone, - listKey, - item: { id, data: { author: 'Sam', url: 'hi-ho' } }, - returnFields, - }); - - // The url should not have changed across updates, even though we - // have regenerateOnUpdate: true - expect(result).not.toMatchObject({ url }); - } - ) - ); - - test( - 'Has a stable unique ID across regenerations when original conflict is gone', - // 1. { id } = Create { name: "Hi Ho" } (slug: 'hi-ho') - // 2. { id: newId } = Create { name: "Hi Ho" } (slug: 'hi-ho-dsbwerlk') - // 3. Delete { id } - // 4. Update { id: newId, author: "Sam" } (slug: 'hi-ho-dsbwerlk') - runner( - setupList(adapterName, { - name: { type: Text }, - author: { type: Text }, - url: { type: Slug, from: 'name' }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Hi Ho', url: 'hi-ho' }; - const returnFields = 'id url'; - const originalItem = await createItem({ keystone, listKey, item }); - const { id, url } = await createItem({ keystone, listKey, item, returnFields }); - - // The slug has been uniquified - expect(url).toMatch(/hi-ho-[a-zA-Z0-9]+/); - - // delete the original created item - await deleteItem({ keystone, listKey, itemId: originalItem.id }); - - const result = await updateItem({ - keystone, - listKey, - item: { id, data: { author: 'Sam' } }, - returnFields, - }); - - // The url should not have changed across updates, even though we - // have regenerateOnUpdate: true - expect(result).toMatchObject({ url }); - } - ) - ); - - test( - 'Does not have a stable unique ID across regenerations when original conflict is gone, when a slug is supplied', - // 1. { id } = Create { name: "Hi Ho" } (slug: 'hi-ho') - // 2. { id: newId } = Create { name: "Hi Ho", slug: "hi-ho" } (slug: 'hi-ho-dsbwerlk') - // 3. Delete { id } - // 4. Update { id: newId, author: "Sam", slug: "hi-ho" } (slug: 'hi-ho-dsbwerlk') - runner( - setupList(adapterName, { - name: { type: Text }, - author: { type: Text }, - url: { type: Slug, from: 'name' }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Hi Ho', url: 'hi-ho' }; - const returnFields = 'id url'; - const originalItem = await createItem({ keystone, listKey, item }); - const { id, url } = await createItem({ keystone, listKey, item, returnFields }); - - // The slug has been uniquified - expect(url).toMatch(/hi-ho-[a-zA-Z0-9]+/); - - // delete the original created item - await deleteItem({ keystone, listKey, itemId: originalItem.id }); - - const result = await updateItem({ - keystone, - listKey, - item: { id, data: { author: 'Sam', url: 'hi-ho' } }, - returnFields, - }); - - // The url should not have changed across updates, even though we - // have regenerateOnUpdate: true - expect(result).not.toMatchObject({ url }); - } - ) - ); - - test('Uses supplied slug even when regenerateOnUpdate: false', () => { - const generate = jest.fn(); - return runner( - setupList(adapterName, { - name: { type: Text }, - url: { type: Slug, generate, regenerateOnUpdate: false }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Awesome Sauce', url: 'awesome-sauce' }; - const { id } = await createItem({ keystone, listKey, item }); - const result = await updateItem({ - keystone, - listKey, - item: { id, data: { name: 'Something Else', url: 'something-something' } }, - returnFields: 'url', - }); - expect(result).toMatchObject({ url: 'something-something' }); - expect(generate).not.toHaveBeenCalled(); - } - )(); - }); - - test('Has a stable slug when regenerateOnUpdate: false', () => { - const generate = jest.fn(); - return runner( - setupList(adapterName, { - name: { type: Text }, - url: { type: Slug, generate, regenerateOnUpdate: false }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Awesome Sauce', url: 'awesome-sauce' }; - const { id } = await createItem({ keystone, listKey, item }); - const result = await updateItem({ - keystone, - listKey, - item: { id, data: { name: 'Something Else' } }, - returnFields: 'url', - }); - expect(result).toMatchObject({ url: 'awesome-sauce' }); - expect(generate).not.toHaveBeenCalled(); - } - )(); - }); - - test( - 'Regenerates & Uniquifies by default', - runner( - setupList(adapterName, { - name: { type: Text }, - url: { type: Slug, from: 'name' }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item1 = { name: 'Awesome Sauce', url: 'awesome-sauce' }; - const item2 = { name: 'This Post', url: 'this-post' }; - await createItem({ keystone, listKey, item: item1 }); - const { id } = await createItem({ keystone, listKey, item: item2 }); - const result = await updateItem({ - keystone, - listKey, - item: { id, data: { name: item1.name } }, - returnFields: 'url', - }); - expect(result).toMatchObject({ - url: expect.stringMatching(/awesome-sauce-[a-zA-Z0-9]+/), - }); - } - ) - ); - }); - - describe('error handling', () => { - test("throws if 'regenerateOnUpdate' is not a bool", () => { - return expect( - runner( - setupList(adapterName, { - url: { type: Slug, from: 'foo', regenerateOnUpdate: 13 }, - }) - )() - ).rejects.toThrow(/The 'regenerateOnUpdate' option on .*\.url must be true\/false/); - }); - - test("throws if 'alwaysMakeUnique' is not a bool", () => { - return expect( - runner( - setupList(adapterName, { - url: { type: Slug, from: 'foo', alwaysMakeUnique: 13 }, - }) - )() - ).rejects.toThrow(/The 'alwaysMakeUnique' option on .*\.url must be true\/false/); - }); - - test("throws if both 'from' and 'generate' specified", () => { - return expect( - runner( - setupList(adapterName, { url: { type: Slug, from: 'foo', generate: () => {} } }) - )() - ).rejects.toThrow("Only one of 'from' or 'generate' can be supplied"); - }); - - test("throws if 'from' is not a string", () => { - return expect( - runner(setupList(adapterName, { url: { type: Slug, from: 12 } }))() - ).rejects.toThrow(/The 'from' option on .* must be a string/); - }); - - test("throws if 'from' is a function", () => { - return expect( - runner(setupList(adapterName, { url: { type: Slug, from: () => {} } }))() - ).rejects.toThrow(/A function was specified for the 'from' option/); - }); - - test("throws when 'makeUnique' isn't a function", () => { - return expect( - runner(setupList(adapterName, { url: { type: Slug, makeUnique: 'foo' } }))() - ).rejects.toThrow( - /The 'makeUnique' option on .* must be a function, but received string/ - ); - }); - - test( - "throws when 'from' field doesn't exist", - runner( - setupList(adapterName, { - url: { type: Slug, from: 'name' }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - try { - await createItem({ keystone, listKey, item: {} }); - expect(true).toEqual(false); - } catch (error) { - expect(error).not.toBe(undefined); - expect(error.message).toMatch( - /The field 'name' does not exist on the list '.*' as specified in the 'from' option of '.*\.url'/ - ); - } - } - ) - ); - - test("throws when 'generate' returns a non-string", () => { - runner( - setupList(adapterName, { - url: { type: Slug, generate: () => 12 }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - try { - await createItem({ keystone, listKey, item: {} }); - expect(true).toEqual(false); - } catch (error) { - expect(error).not.toBe(undefined); - expect(error.message).toMatch( - /.*\.url's 'generate' option resolved with a number, but expected a string./ - ); - } - } - ); - }); - - test( - "throws when 'makeUnique' returns a non-string", - runner( - setupList(adapterName, { - name: { type: Text }, - url: { type: Slug, makeUnique: () => 12 }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Make Unique', url: 'make-unique' }; - await createItem({ keystone, listKey, item }); - try { - await createItem({ keystone, listKey, item }); - expect(true).toEqual(false); - } catch (error) { - expect(error).not.toBe(undefined); - expect(error.message).toMatch( - /.*\.url's 'makeUnique' option resolved with a number, but expected a string./ - ); - } - } - ) - ); - - test( - "throws when 'makeUnique' can't generate a unique value", - runner( - setupList(adapterName, { - name: { type: Text }, - url: { type: Slug, makeUnique: ({ slug }: { slug: string }) => slug }, - }), - async ({ keystone }) => { - const { key: listKey } = keystone.listsArray[0]; - const item = { name: 'Make Unique', url: 'make-unique' }; - await createItem({ keystone, listKey, item }); - try { - await createItem({ keystone, listKey, item }); - expect(true).toEqual(false); - } catch (error) { - expect(error).not.toBe(undefined); - expect(error.message).toMatch(/failed after too many attempts/); - } - } - ) - ); - }); - }) - ); -});