From d24f989e93cb87aaec7ad5d5a3fd6f9bfd04a997 Mon Sep 17 00:00:00 2001 From: Bart Duisters <43420049+bartduisters@users.noreply.github.com> Date: Mon, 13 Jan 2020 22:43:13 +0100 Subject: [PATCH 01/93] Same PR without en-US corrections (#2232) --- docs/guides/mutation-lifecycle.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/guides/mutation-lifecycle.md b/docs/guides/mutation-lifecycle.md index 5aa99c5b893..aac1835637a 100644 --- a/docs/guides/mutation-lifecycle.md +++ b/docs/guides/mutation-lifecycle.md @@ -99,7 +99,7 @@ The first step in all mutations is to check that the user has access to perform If access control has been defined statically or imperatively this check can be performed here. An `AccessDeniedError` is returned if the access control failed. If the access control mechanism for this list is defined declaratively (i.e using a GraphQL `where` statement), this check is deferred until the next step. -For more information on how to define access control, please consult the [access control documentation](/docs/guides/access-control.md)). +For more information on how to define access control, please consult the [access control documentation](/docs/guides/access-control.md). #### 2. Get Item(s) (`update/delete`) @@ -117,7 +117,7 @@ No error is thrown if some items do not exist or do not pass access control. The field access permissions can now be checked. -Only those fields which are being set/updated have their permissions checks. +Only those fields which are being set/updated have their permissions checked. All relevant fields for all targeted items are checked in parallel and if any of them fail an `AccessDeniedError` is returned, listing all the fields which violated access control. @@ -127,13 +127,13 @@ During the Operational Phase for a `single` mutation, the following steps are pe The Operational Phase for a `many` mutation will perform the Operational Phase for the corresponding `single` mutation across each item in parallel. -The Operational Phase consists of seven distinct steps. +The Operational Phase consists of the following distinct steps. #### 1. Resolve Defaults (`create`) The first step when creating a new item is to resolve any default values. -Any fields which a) are not set on the provided item and b) have a configured default value will be set to the default value. +Any fields which are not set on the provided item _and_ have a configured default value will be set to the default value. The default value of a field can be configured at `List` definition time with the config attribute `defaultValue`. @@ -153,7 +153,7 @@ This step performs the necessary database queries to identify the appropriate it In the case that a nested mutation specifies a `create` operation, this will trigger a full `createMutation` on the related `List`. -Any errors thrown by this nested `createMutation` will be cause the current mutation to terminate, and the errors will be passed up the call stack. +Any errors thrown by this nested `createMutation` will cause the current mutation to terminate, and the errors will be passed up the call stack. As well as resolving the IDs and performing any nested create mutations, this step must also track. @@ -165,7 +165,7 @@ A backlink exists when a relationship field is configured with a `ref` attribute During this step, any backlinks which need to be updated are identified and registered internally. -The actual update step for these backlinks will be performed during the [Resolve backlinks] step, once all other pre-hooks and database operations have been completed on the primary target list. +The actual update step for these backlinks will be performed during the `Resolve backlinks` step, once all other pre-hooks and database operations have been completed on the primary target list. #### 3. Resolve Input (`create/update`) @@ -196,11 +196,11 @@ The database operation is where the keystone database adapter is used to make th During this stage, all pending backlinks which need to be updated on referenced lists are resolved. This involves performing an `updateMutation` on the referenced list, performing either a `connect` or `disconnect` operation on the referenced relationship field. -Unlike the [Resolve relationship] step, this operation will only ever nest one level deep. +Unlike the `Resolve relationship` step, this operation will only ever nest one level deep. It can still result in either an `AccessDeniedError` or `ValidationFailureError`. -As with [Resolve relationship], the nested `AfterChange` hooks will be returned an added to the stack of deferred hooks for this mutation. +As with `Resolve relationship`, the nested `AfterChange` hooks will be returned an added to the stack of deferred hooks for this mutation. #### 8. After Operation (`create/update/delete`) From 9ddc71c2e4e945e01d15e61f82a536b0f5dc795c Mon Sep 17 00:00:00 2001 From: Bart Duisters <43420049+bartduisters@users.noreply.github.com> Date: Mon, 13 Jan 2020 23:48:16 +0100 Subject: [PATCH 02/93] Update custom-server.md (#2225) * Update custom-server.md Corrected position of some sentences * Apply suggestions from code review Co-Authored-By: Mike Co-authored-by: Mike --- docs/guides/custom-server.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/guides/custom-server.md b/docs/guides/custom-server.md index b3b82b47a67..7c3bfd11a21 100644 --- a/docs/guides/custom-server.md +++ b/docs/guides/custom-server.md @@ -15,7 +15,7 @@ handles the GraphQL API and Admin UI. Things such as: - Add additional routes - Setup additional server middleware (`compress`/`brotli`/etc) - Notify a 3rd party service when the API is ready -- ... etc +- ... A **Custom Server** can replace the default and act as the entry point to your application which consumes your [schema definition](/docs/guides/schema.md). A Custom @@ -27,8 +27,10 @@ available in KeystoneJS include: - [Static App](/packages/app-static/README.md) for serving static files. - [Next.js App](/packages/app-next/README.md) for serving a Next.js App on the same server as the API - The following are some possible ways of setting up a custom server, roughly in - order of complexity. +- [Nuxt.js App](/packages/app-nuxt/README.md) for serving a Nuxt.js App on the same server as the API + + +Following are some possible ways of setting up a custom server, roughly in order of complexity. ## You might not need a custom server if... @@ -129,7 +131,7 @@ keystone ## Custom Server with manual middleware preparation -For really fine-grained control, a custom server skip calling +For really fine-grained control, a custom server can skip calling `keystone.prepare()` in favour of calling an app's `.prepareMiddleware()` function directly. From 11631fc818843b5faefa28576a44cb30ef07b130 Mon Sep 17 00:00:00 2001 From: Bart Duisters <43420049+bartduisters@users.noreply.github.com> Date: Mon, 13 Jan 2020 23:56:27 +0100 Subject: [PATCH 03/93] Update relationships.md (#2212) * Update relationships.md Rewrite some of the sentences. Add extra information and examples for clarification. * Apply suggestions from code review Co-Authored-By: Mike Co-authored-by: Mike --- docs/guides/relationships.md | 59 +++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/docs/guides/relationships.md b/docs/guides/relationships.md index 2911bebd93a..e8467a1b074 100644 --- a/docs/guides/relationships.md +++ b/docs/guides/relationships.md @@ -7,23 +7,43 @@ order: 4 # Creating Relationships Between Lists +This chapter assumes that that the reader has the code that was created in [Setup - Chapter 1](https://www.keystonejs.com/guides/new-project) and [Setup - Chapter 2](https://www.keystonejs.com/guides/add-lists). + ## Pick assignee from Users collection (to-single relationship) -Now, when we've created all necessary lists, let's link them together by setting up +Let's link the Todo list and the User list together by setting up a `relationship`. Tweak the `assignee` field in `Todos.js` to match the following code: +Import the `Relationship` field: + ```javascript -{ +const { Text, CalendarDay, Checkbox, Relationship } = require('@keystonejs/fields'); +``` + +Old code: + +```javascript +assignee: { + type: Text, + isRequired: true, +}, +``` + +New code: + +```javascript +assignee: { type: Relationship, ref: 'User', -} + isRequired: true, +}, ``` -Don't forget to import the `Relationship` type. The `ref` option defines the collection to which we will relate. Give the name assigned to the desired list, as passed to `createList`. In the admin panel you can now pick one of created users to make them responsible for completing the task. +The `ref` option defines the collection to which we will relate. The name assigned to the option is the same name that is passed to `createList`. In the AdminUI you can now pick one of the created users to make them responsible for completing the task. ## Pick task from Todos collection (two-way to-single relationship) -Now we can assign a task to a user, but can't assign the user a task! Let's fix this. +Is is now possible to assign a task to a user, but it is not possible to assign the user to a task! Let's fix this. In `Users.js` add the following: ```javascript @@ -36,11 +56,11 @@ module.exports = { }; ``` -Now we can set a task for the User from the admin panel. But something is wrong! When we pick a task for the user and then check this task, the assignee is incorrect. We can solve this by... +Now we can set a task for the User from the admin panel. But something is wrong! When we pick a task for the user and then check this task, the assignee is incorrect. This can be solved by using a `Back Reference`. ## Enabling Back Reference between Users and Todos -Back Reference is KeystoneJS' mechanism that can overwrite fields of the referenced entity. +`Back Reference` is KeystoneJS' mechanism that can overwrite fields of the referenced entity. It is better seen in action, so let's write some code first. In `Users.js` adjust the `task` field to the following: @@ -61,11 +81,28 @@ assignee: { } ``` -Take a look at admin panel and try to assign a Todo to a user. Notice that the user's `task` field is already set! Ensure that this works the other way. +Start the AdminUI and create a Todo and assign a user. Check the user's `task` field and notice that it is already set! When a user is created and a Todo is assigned, the `assignee` field won't be filled in. Add the following code to make it work both ways: +```javascript +task: { + type: Relationship, + ref: 'Todo.assignee', +} +``` + +To test it out, remove the `isRequired: true` property from the `assignee` field in the `Todos` file: + +```javascript +assignee: { + type: Relationship, + ref: 'User.task', + // isRequired: true, +}, +``` +Create a Todo without assigning it to a user. Then go to a user, assign a Todo to the `task` field. Go back to the todo and notice that the `assignee` field has been filled in. -## Assigning a user to multiple tasks (to-many relationship) +## Assigning multiple tasks to a user (to-many relationship) -What if we need user to do multiple tasks? KeystoneJS provides a way to do this easily. +What if a user needs to be able to do multiple tasks? KeystoneJS provides a way to do this easily. Take a look at following code in `Users.js`: ```javascript @@ -76,7 +113,7 @@ tasks: { } ``` -The `many: true` option indicates that `User` can store multiple references to tasks. Note that we've changed `task` to `tasks`. Copy this code to your application and don't forget to change the `assignee` field in `Todos.js` to match the new field name. Now in the admin UI you can pick multiple tasks for a user. +The `many: true` option indicates that `User` can store multiple references to tasks. Note that we've changed `task` to `tasks`. Copy this code to your application and don't forget to change the `assignee` field in `Todos.js` to match the new field name `User.tasks`. Now in the AdminUI you can pick multiple tasks for a user. See also: From fef55b54785cc4276f93cd2a145b56e4823041e6 Mon Sep 17 00:00:00 2001 From: Tim Leslie Date: Tue, 14 Jan 2020 10:17:57 +1100 Subject: [PATCH 04/93] Simplify parsing with a separate modifier tokenizer (#2229) --- .../mongo-join-builder/lib/query-parser.js | 71 ++++++------ packages/mongo-join-builder/lib/tokenizers.js | 104 ++++++++---------- .../tests/relationship-path.test.js | 28 +++-- .../mongo-join-builder/tests/simple.test.js | 14 +-- 4 files changed, 105 insertions(+), 112 deletions(-) diff --git a/packages/mongo-join-builder/lib/query-parser.js b/packages/mongo-join-builder/lib/query-parser.js index 515de1a1eda..7ad7b5ae1f3 100644 --- a/packages/mongo-join-builder/lib/query-parser.js +++ b/packages/mongo-join-builder/lib/query-parser.js @@ -1,7 +1,7 @@ const cuid = require('cuid'); const { getType, flatten, objMerge } = require('@keystonejs/utils'); -const { simpleTokenizer, relationshipTokenizer } = require('./tokenizers'); +const { simpleTokenizer, relationshipTokenizer, modifierTokenizer } = require('./tokenizers'); // If it's 0 or 1 items, we can use it as-is. Any more needs an $and/$or const joinTerms = (matchTerms, joinOp) => @@ -12,11 +12,11 @@ const flattenQueries = (parsedQueries, joinOp) => ({ parsedQueries.map(q => q.matchTerm).filter(matchTerm => matchTerm), joinOp ), - postJoinPipeline: flatten(parsedQueries.map(q => q.postJoinPipeline)).filter(pipe => pipe), - relationships: objMerge(parsedQueries.map(q => q.relationships)), + postJoinPipeline: flatten(parsedQueries.map(q => q.postJoinPipeline || [])).filter(pipe => pipe), + relationships: objMerge(parsedQueries.map(q => q.relationships || {})), }); -function parser({ listAdapter, getUID = cuid }, query, pathSoFar = [], include) { +function queryParser({ listAdapter, getUID = cuid }, query, pathSoFar = [], include) { if (getType(query) !== 'Object') { throw new Error( `Expected an Object for query, got ${getType(query)} at path ${pathSoFar.join('.')}` @@ -30,45 +30,40 @@ function parser({ listAdapter, getUID = cuid }, query, pathSoFar = [], include) if (['AND', 'OR'].includes(key)) { // An AND/OR query component return flattenQueries( - value.map((_query, index) => parser({ listAdapter, getUID }, _query, [...path, index])), + value.map((_query, index) => + queryParser({ listAdapter, getUID }, _query, [...path, index]) + ), { AND: '$and', OR: '$or' }[key] ); - } else if (getType(value) === 'Object') { - if (key === 'id') { - return { matchTerm: { _id: value }, postJoinPipeline: [], relationshipTokenizer: {} }; + } else if (['$search', '$orderBy', '$skip', '$first', '$count'].includes(key)) { + return { postJoinPipeline: [modifierTokenizer(listAdapter, query, key, path)] }; + } else if (key === 'id') { + if (getType(value) === 'Object') { + return { matchTerm: { _id: value } }; } else { - // A relationship query component - const uid = getUID(key); - const { matchTerm, relationshipInfo } = relationshipTokenizer( - listAdapter, - query, - key, - path, - uid - ); - return { - // matchTerm is our filtering expression. This determines if the - // parent item is included in the final list - matchTerm, - postJoinPipeline: [], - relationships: { - [uid]: { relationshipInfo, ...parser({ listAdapter, getUID }, value, path) }, - }, - }; - } - } else { - // A simple field query component - const queryAst = simpleTokenizer(listAdapter, query, key, path); - if (getType(queryAst) !== 'Object') { - throw new Error( - `Must return an Object from 'simpleTokenizer' function, given ${path.join('.')}` - ); + return { matchTerm: simpleTokenizer(listAdapter, query, key, path) }; } + } else if (getType(value) === 'Object') { + // A relationship query component + const uid = getUID(key); + const { matchTerm, relationshipInfo } = relationshipTokenizer( + listAdapter, + query, + key, + path, + uid + ); return { - matchTerm: queryAst.matchTerm, - postJoinPipeline: queryAst.postJoinPipeline || [], - relationships: {}, + // matchTerm is our filtering expression. This determines if the + // parent item is included in the final list + matchTerm, + relationships: { + [uid]: { relationshipInfo, ...queryParser({ listAdapter, getUID }, value, path) }, + }, }; + } else { + // A simple field query component + return { matchTerm: simpleTokenizer(listAdapter, query, key, path) }; } }); const flatQueries = flattenQueries(parsedQueries, '$and'); @@ -81,4 +76,4 @@ function parser({ listAdapter, getUID = cuid }, query, pathSoFar = [], include) }; } -module.exports = { queryParser: parser }; +module.exports = { queryParser }; diff --git a/packages/mongo-join-builder/lib/tokenizers.js b/packages/mongo-join-builder/lib/tokenizers.js index b4ed5a0fa2e..3cfb7766d9b 100644 --- a/packages/mongo-join-builder/lib/tokenizers.js +++ b/packages/mongo-join-builder/lib/tokenizers.js @@ -7,6 +7,12 @@ const getRelatedListAdapterFromQueryPath = (listAdapter, queryPath) => { let foundListAdapter = listAdapter; + // NOTE: We slice the last path segment off because we're interested in the + // related list, not the field on the related list. ie, if the path is + // ['posts', 'comments', 'author_some'], + // the "virtual" field is 'author', and the related list is the one at + // ['posts', 'comments'] + queryPath = queryPath.slice(0, -1); for (let index = 0; index < queryPath.length; index++) { const segment = queryPath[index]; if (segment === 'AND' || segment === 'OR') { @@ -48,12 +54,7 @@ const getRelatedListAdapterFromQueryPath = (listAdapter, queryPath) => { }; const relationshipTokenizer = (listAdapter, query, queryKey, path, uid) => { - // NOTE: We slice the last path segment off because we're interested in the - // related list, not the field on the related list. ie, if the path is - // ['posts', 'comments', 'author_some'], - // the "virtual" field is 'author', and the related list is the one at - // ['posts', 'comments'] - const refListAdapter = getRelatedListAdapterFromQueryPath(listAdapter, path.slice(0, -1)); + const refListAdapter = getRelatedListAdapterFromQueryPath(listAdapter, path); const fieldAdapter = refListAdapter.findFieldAdapterForQuerySegment(queryKey); // Nothing found, return an empty operation @@ -86,64 +87,49 @@ const relationshipTokenizer = (listAdapter, query, queryKey, path, uid) => { }; const simpleTokenizer = (listAdapter, query, queryKey, path) => { - // NOTE: We slice the last path segment off because we're interested in the - // related list, not the field on the related list. ie, if the path is - // ['posts', 'comments', 'author', 'name'], - // the field is 'name', and the related list is the one at - // ['posts', 'comments', 'author'] - const refListAdapter = getRelatedListAdapterFromQueryPath(listAdapter, path.slice(0, -1)); - const simpleQueryConditions = { - ...objMerge( - refListAdapter.fieldAdapters.map(fieldAdapter => - fieldAdapter.getQueryConditions(fieldAdapter.dbPath) - ) - ), - }; + const refListAdapter = getRelatedListAdapterFromQueryPath(listAdapter, path); + const simpleQueryConditions = objMerge( + refListAdapter.fieldAdapters.map(a => a.getQueryConditions(a.dbPath)) + ); if (queryKey in simpleQueryConditions) { - return { matchTerm: simpleQueryConditions[queryKey](query[queryKey], query) }; - } - - if (queryKey in modifierConditions) { - return { - postJoinPipeline: [modifierConditions[queryKey](query[queryKey], query, refListAdapter)], - }; + return simpleQueryConditions[queryKey](query[queryKey], query); } - - // Nothing found, return an empty operation - // TODO: warn? - return {}; }; -const modifierConditions = { - // TODO: Implement configurable search fields for lists - $search: value => { - if (!value || (getType(value) === 'String' && !value.trim())) { - return undefined; - } - return { $match: { name: new RegExp(`${escapeRegExp(value)}`, 'i') } }; - }, - - $orderBy: (value, _, listAdapter) => { - const [orderField, orderDirection] = value.split('_'); - - const mongoField = listAdapter.graphQlQueryPathToMongoField(orderField); - - return { $sort: { [mongoField]: orderDirection === 'DESC' ? -1 : 1 } }; - }, - - $skip: value => { - if (value < Infinity && value > 0) { - return { $skip: value }; - } - }, +const modifierTokenizer = (listAdapter, query, queryKey, path) => { + const refListAdapter = getRelatedListAdapterFromQueryPath(listAdapter, path); + return { + // TODO: Implement configurable search fields for lists + $search: value => { + if (!value || (getType(value) === 'String' && !value.trim())) { + return undefined; + } + return { $match: { name: new RegExp(`${escapeRegExp(value)}`, 'i') } }; + }, + $orderBy: (value, _, listAdapter) => { + const [orderField, orderDirection] = value.split('_'); - $first: value => { - if (value < Infinity && value > 0) { - return { $limit: value }; - } - }, + const mongoField = listAdapter.graphQlQueryPathToMongoField(orderField); - $count: value => ({ $count: value }), + return { $sort: { [mongoField]: orderDirection === 'DESC' ? -1 : 1 } }; + }, + $skip: value => { + if (value < Infinity && value > 0) { + return { $skip: value }; + } + }, + $first: value => { + if (value < Infinity && value > 0) { + return { $limit: value }; + } + }, + $count: value => ({ $count: value }), + }[queryKey](query[queryKey], query, refListAdapter); }; -module.exports = { simpleTokenizer, relationshipTokenizer, getRelatedListAdapterFromQueryPath }; +module.exports = { + simpleTokenizer, + relationshipTokenizer, + modifierTokenizer, + getRelatedListAdapterFromQueryPath, +}; diff --git a/packages/mongo-join-builder/tests/relationship-path.test.js b/packages/mongo-join-builder/tests/relationship-path.test.js index e7e583bcf9c..28d965589ab 100644 --- a/packages/mongo-join-builder/tests/relationship-path.test.js +++ b/packages/mongo-join-builder/tests/relationship-path.test.js @@ -16,7 +16,7 @@ describe('Relationship Path parser', () => { barListAdapter = { findFieldAdapterForQuerySegment: jest.fn(key => fieldAdapters[key]) }; zipListAdapter = {}; - expect(getRelatedListAdapterFromQueryPath(fooListAdapter, ['bar', 'zip'])).toEqual( + expect(getRelatedListAdapterFromQueryPath(fooListAdapter, ['bar', 'zip', 'ignore'])).toEqual( zipListAdapter ); }); @@ -26,9 +26,9 @@ describe('Relationship Path parser', () => { const fieldAdapter = { getRefListAdapter: jest.fn(() => listAdapter) }; listAdapter = { findFieldAdapterForQuerySegment: jest.fn(() => fieldAdapter) }; - expect(getRelatedListAdapterFromQueryPath(listAdapter, ['foo', 'foo', 'foo'])).toEqual( - listAdapter - ); + expect( + getRelatedListAdapterFromQueryPath(listAdapter, ['foo', 'foo', 'foo', 'ignore']) + ).toEqual(listAdapter); }); test('Handles arbitrary path strings correctly', () => { @@ -46,7 +46,7 @@ describe('Relationship Path parser', () => { zipListAdapter = {}; expect( - getRelatedListAdapterFromQueryPath(fooListAdapter, ['bar_koodle', 'zip-boom_zap']) + getRelatedListAdapterFromQueryPath(fooListAdapter, ['bar_koodle', 'zip-boom_zap', 'ignore']) ).toEqual(zipListAdapter); }); @@ -65,7 +65,13 @@ describe('Relationship Path parser', () => { zipListAdapter = {}; expect( - getRelatedListAdapterFromQueryPath(fooListAdapter, ['bar_koodle', 'AND', 1, 'zip-boom_zap']) + getRelatedListAdapterFromQueryPath(fooListAdapter, [ + 'bar_koodle', + 'AND', + 1, + 'zip-boom_zap', + 'ignore', + ]) ).toEqual(zipListAdapter); }); @@ -84,7 +90,13 @@ describe('Relationship Path parser', () => { zipListAdapter = {}; expect( - getRelatedListAdapterFromQueryPath(fooListAdapter, ['bar_koodle', 'OR', 1, 'zip-boom_zap']) + getRelatedListAdapterFromQueryPath(fooListAdapter, [ + 'bar_koodle', + 'OR', + 1, + 'zip-boom_zap', + 'ignore', + ]) ).toEqual(zipListAdapter); }); }); @@ -106,7 +118,7 @@ describe('Relationship Path parser', () => { zipListAdapter = {}; expect(() => - getRelatedListAdapterFromQueryPath(fooListAdapter, ['bar_koodle', 'zip-boom_zap']) + getRelatedListAdapterFromQueryPath(fooListAdapter, ['bar_koodle', 'zip-boom_zap', 'ignore']) ).toThrow( /'foo' Mongo List Adapter failed to determine field responsible for the query condition 'bar_koodle'/ ); diff --git a/packages/mongo-join-builder/tests/simple.test.js b/packages/mongo-join-builder/tests/simple.test.js index 163c0a61293..834aaf48f67 100644 --- a/packages/mongo-join-builder/tests/simple.test.js +++ b/packages/mongo-join-builder/tests/simple.test.js @@ -1,4 +1,4 @@ -const { simpleTokenizer } = require('../lib/tokenizers'); +const { simpleTokenizer, modifierTokenizer } = require('../lib/tokenizers'); describe('Simple tokenizer', () => { test('Uses correct conditions', () => { @@ -7,7 +7,7 @@ describe('Simple tokenizer', () => { const listAdapter = { fieldAdapters: [{ getQueryConditions }] }; expect(simpleTokenizer(listAdapter, { name: 'hi' }, 'name', ['name'])).toMatchObject({ - matchTerm: { foo: 'bar' }, + foo: 'bar', }); expect(getQueryConditions).toHaveBeenCalledTimes(1); }); @@ -17,10 +17,10 @@ describe('Simple tokenizer', () => { const getQueryConditions = jest.fn(() => simpleConditions); const listAdapter = { fieldAdapters: [{ getQueryConditions }] }; - expect(simpleTokenizer(listAdapter, { $count: 'hi' }, '$count', ['$count'])).toMatchObject({ - postJoinPipeline: [{ $count: 'hi' }], + expect(modifierTokenizer(listAdapter, { $count: 'hi' }, '$count', ['$count'])).toMatchObject({ + $count: 'hi', }); - expect(getQueryConditions).toHaveBeenCalledTimes(1); + expect(getQueryConditions).toHaveBeenCalledTimes(0); }); test('returns empty array when no matches found', () => { @@ -29,7 +29,7 @@ describe('Simple tokenizer', () => { const listAdapter = { fieldAdapters: [{ getQueryConditions }] }; const result = simpleTokenizer(listAdapter, { name: 'hi' }, 'name', ['name']); - expect(result).toMatchObject({}); + expect(result).toBe(undefined); expect(getQueryConditions).toHaveBeenCalledTimes(1); }); @@ -40,7 +40,7 @@ describe('Simple tokenizer', () => { const listAdapter = { fieldAdapters: [{ getQueryConditions }] }; expect(simpleTokenizer(listAdapter, { name: 'hi' }, 'name', ['name'])).toMatchObject({ - matchTerm: { foo: 'bar' }, + foo: 'bar', }); expect(getQueryConditions).toHaveBeenCalledTimes(1); expect(nameConditions).toHaveBeenCalledTimes(1); From 115860350aa901749d240cb275cada29b8d541f8 Mon Sep 17 00:00:00 2001 From: Vladimir Barcovsky Date: Tue, 14 Jan 2020 03:33:06 +0400 Subject: [PATCH 05/93] Close issue #1548 (#2211) Co-authored-by: Mike --- .changeset/lazy-hats-begin.md | 5 +++++ packages/fields/src/types/Relationship/views/Field.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/lazy-hats-begin.md diff --git a/.changeset/lazy-hats-begin.md b/.changeset/lazy-hats-begin.md new file mode 100644 index 00000000000..c59688e1166 --- /dev/null +++ b/.changeset/lazy-hats-begin.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/fields': patch +--- + +Fixed work of relation select diff --git a/packages/fields/src/types/Relationship/views/Field.js b/packages/fields/src/types/Relationship/views/Field.js index d154d7da0b6..32e407c5fc0 100644 --- a/packages/fields/src/types/Relationship/views/Field.js +++ b/packages/fields/src/types/Relationship/views/Field.js @@ -169,7 +169,7 @@ export default class RelationshipField extends Component { const { field, onChange } = this.props; const { many } = field.config; if (many) { - onChange(option.map(i => i.value)); + onChange(option ? option.map(i => i.value) : []); } else { onChange(option ? option.value : null); } From 3802b3f60165875686be628b2b0b6d4fbb79499d Mon Sep 17 00:00:00 2001 From: Tim Leslie Date: Tue, 14 Jan 2020 13:52:55 +1100 Subject: [PATCH 06/93] More filtering tests (#2234) --- .../crud/many-to-many-no-ref.test.js | 98 +++++++++++++++++++ .../relationships/crud/many-to-many.test.js | 98 +++++++++++++++++++ .../relationships/crud/one-to-many.test.js | 98 +++++++++++++++++++ docs/guides/custom-server.md | 1 - docs/guides/relationships.md | 4 +- yarn.lock | 68 ------------- 6 files changed, 297 insertions(+), 70 deletions(-) diff --git a/api-tests/relationships/crud/many-to-many-no-ref.test.js b/api-tests/relationships/crud/many-to-many-no-ref.test.js index 09e1a189b3b..dd40243c333 100644 --- a/api-tests/relationships/crud/many-to-many-no-ref.test.js +++ b/api-tests/relationships/crud/many-to-many-no-ref.test.js @@ -64,6 +64,41 @@ const getCompanyAndLocation = async (keystone, companyId, locationId) => { return data; }; +const createReadData = async keystone => { + // create locations [A, A, B, B, C, C]; + const { data } = await graphqlRequest({ + keystone, + query: `mutation create($locations: [LocationsCreateInput]) { createLocations(data: $locations) { id name } }`, + variables: { + locations: ['A', 'A', 'B', 'B', 'C', 'C'].map(name => ({ data: { name } })), + }, + }); + const { createLocations } = data; + await Promise.all( + [ + [0, 1, 2, 3, 4, 5], // -> [A, A, B, B, C, C] + [0, 2, 4], // -> [A, B, C] + [0, 1], // -> [A, A] + [0, 2], // -> [A, B] + [0, 4], // -> [A, C] + [2, 3], // -> [B, B] + [0], // -> [A] + [2], // -> [B] + [], // -> [] + ].map(async locationIdxs => { + const ids = locationIdxs.map(i => ({ id: createLocations[i].id })); + const { data } = await graphqlRequest({ + keystone, + query: `mutation create($locations: [LocationWhereUniqueInput]) { createCompany(data: { + locations: { connect: $locations } + }) { id locations { name }}}`, + variables: { locations: ids }, + }); + return data.createCompany; + }) + ); +}; + multiAdapterRunners().map(({ runner, adapterName }) => describe(`Adapter: ${adapterName}`, () => { // 1:1 relationships are symmetric in how they behave, but @@ -108,6 +143,69 @@ multiAdapterRunners().map(({ runner, adapterName }) => }); } + describe('Read', () => { + test( + '_some', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 6], + ['B', 5], + ['C', 3], + ['D', 0], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allCompanies(where: { locations_some: { name: "${name}"}}) { id }}`, + }); + expect(data.allCompanies.length).toEqual(count); + }) + ); + }) + ); + test( + '_none', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 3], + ['B', 4], + ['C', 6], + ['D', 9], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allCompanies(where: { locations_none: { name: "${name}"}}) { id }}`, + }); + expect(data.allCompanies.length).toEqual(count); + }) + ); + }) + ); + test( + '_every', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 3], + ['B', 3], + ['C', 1], + ['D', 1], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allCompanies(where: { locations_every: { name: "${name}"}}) { id }}`, + }); + expect(data.allCompanies.length).toEqual(count); + }) + ); + }) + ); + }); + describe('Create', () => { test( 'With connect', diff --git a/api-tests/relationships/crud/many-to-many.test.js b/api-tests/relationships/crud/many-to-many.test.js index 58683c516e3..6d705915a23 100644 --- a/api-tests/relationships/crud/many-to-many.test.js +++ b/api-tests/relationships/crud/many-to-many.test.js @@ -65,6 +65,41 @@ const getCompanyAndLocation = async (keystone, companyId, locationId) => { return data; }; +const createReadData = async keystone => { + // create locations [A, A, B, B, C, C]; + const { data } = await graphqlRequest({ + keystone, + query: `mutation create($locations: [LocationsCreateInput]) { createLocations(data: $locations) { id name } }`, + variables: { + locations: ['A', 'A', 'B', 'B', 'C', 'C'].map(name => ({ data: { name } })), + }, + }); + const { createLocations } = data; + await Promise.all( + [ + [0, 1, 2, 3, 4, 5], // -> [A, A, B, B, C, C] + [0, 2, 4], // -> [A, B, C] + [0, 1], // -> [A, A] + [0, 2], // -> [A, B] + [0, 4], // -> [A, C] + [2, 3], // -> [B, B] + [0], // -> [A] + [2], // -> [B] + [], // -> [] + ].map(async locationIdxs => { + const ids = locationIdxs.map(i => ({ id: createLocations[i].id })); + const { data } = await graphqlRequest({ + keystone, + query: `mutation create($locations: [LocationWhereUniqueInput]) { createCompany(data: { + locations: { connect: $locations } + }) { id locations { name }}}`, + variables: { locations: ids }, + }); + return data.createCompany; + }) + ); +}; + multiAdapterRunners().map(({ runner, adapterName }) => describe(`Adapter: ${adapterName}`, () => { // 1:1 relationships are symmetric in how they behave, but @@ -110,6 +145,69 @@ multiAdapterRunners().map(({ runner, adapterName }) => }); } + describe('Read', () => { + test( + '_some', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 6], + ['B', 5], + ['C', 3], + ['D', 0], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allCompanies(where: { locations_some: { name: "${name}"}}) { id }}`, + }); + expect(data.allCompanies.length).toEqual(count); + }) + ); + }) + ); + test( + '_none', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 3], + ['B', 4], + ['C', 6], + ['D', 9], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allCompanies(where: { locations_none: { name: "${name}"}}) { id }}`, + }); + expect(data.allCompanies.length).toEqual(count); + }) + ); + }) + ); + test( + '_every', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 3], + ['B', 3], + ['C', 1], + ['D', 1], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allCompanies(where: { locations_every: { name: "${name}"}}) { id }}`, + }); + expect(data.allCompanies.length).toEqual(count); + }) + ); + }) + ); + }); + describe('Create', () => { test( 'With connect', diff --git a/api-tests/relationships/crud/one-to-many.test.js b/api-tests/relationships/crud/one-to-many.test.js index 87cfe87bc0c..b549dd333ae 100644 --- a/api-tests/relationships/crud/one-to-many.test.js +++ b/api-tests/relationships/crud/one-to-many.test.js @@ -65,6 +65,41 @@ const getCompanyAndLocation = async (keystone, companyId, locationId) => { return data; }; +const createReadData = async keystone => { + // create locations [A, A, B, B, C, C]; + const { data } = await graphqlRequest({ + keystone, + query: `mutation create($locations: [LocationsCreateInput]) { createLocations(data: $locations) { id name } }`, + variables: { + locations: ['A', 'A', 'B', 'B', 'C', 'C'].map(name => ({ data: { name } })), + }, + }); + const { createLocations } = data; + await Promise.all( + [ + [0, 1, 2, 3, 4, 5], // -> [A, A, B, B, C, C] + [0, 2, 4], // -> [A, B, C] + [0, 1], // -> [A, A] + [0, 2], // -> [A, B] + [0, 4], // -> [A, C] + [2, 3], // -> [B, B] + [0], // -> [A] + [2], // -> [B] + [], // -> [] + ].map(async locationIdxs => { + const ids = locationIdxs.map(i => ({ id: createLocations[i].id })); + const { data } = await graphqlRequest({ + keystone, + query: `mutation create($locations: [LocationWhereUniqueInput]) { createCompany(data: { + locations: { connect: $locations } + }) { id locations { name }}}`, + variables: { locations: ids }, + }); + return data.createCompany; + }) + ); +}; + multiAdapterRunners().map(({ runner, adapterName }) => describe(`Adapter: ${adapterName}`, () => { // 1:1 relationships are symmetric in how they behave, but @@ -110,6 +145,69 @@ multiAdapterRunners().map(({ runner, adapterName }) => }); } + describe('Read', () => { + test( + '_some', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 6], + ['B', 5], + ['C', 3], + ['D', 0], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allCompanies(where: { locations_some: { name: "${name}"}}) { id }}`, + }); + expect(data.allCompanies.length).toEqual(count); + }) + ); + }) + ); + test( + '_none', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 3], + ['B', 4], + ['C', 6], + ['D', 9], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allCompanies(where: { locations_none: { name: "${name}"}}) { id }}`, + }); + expect(data.allCompanies.length).toEqual(count); + }) + ); + }) + ); + test( + '_every', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 3], + ['B', 3], + ['C', 1], + ['D', 1], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allCompanies(where: { locations_every: { name: "${name}"}}) { id }}`, + }); + expect(data.allCompanies.length).toEqual(count); + }) + ); + }) + ); + }); + describe('Create', () => { test( 'With connect', diff --git a/docs/guides/custom-server.md b/docs/guides/custom-server.md index 7c3bfd11a21..bfcecfb4456 100644 --- a/docs/guides/custom-server.md +++ b/docs/guides/custom-server.md @@ -28,7 +28,6 @@ available in KeystoneJS include: - [Static App](/packages/app-static/README.md) for serving static files. - [Next.js App](/packages/app-next/README.md) for serving a Next.js App on the same server as the API - [Nuxt.js App](/packages/app-nuxt/README.md) for serving a Nuxt.js App on the same server as the API - Following are some possible ways of setting up a custom server, roughly in order of complexity. diff --git a/docs/guides/relationships.md b/docs/guides/relationships.md index e8467a1b074..990c1fc5777 100644 --- a/docs/guides/relationships.md +++ b/docs/guides/relationships.md @@ -82,6 +82,7 @@ assignee: { ``` Start the AdminUI and create a Todo and assign a user. Check the user's `task` field and notice that it is already set! When a user is created and a Todo is assigned, the `assignee` field won't be filled in. Add the following code to make it work both ways: + ```javascript task: { type: Relationship, @@ -98,7 +99,8 @@ assignee: { // isRequired: true, }, ``` -Create a Todo without assigning it to a user. Then go to a user, assign a Todo to the `task` field. Go back to the todo and notice that the `assignee` field has been filled in. + +Create a Todo without assigning it to a user. Then go to a user, assign a Todo to the `task` field. Go back to the todo and notice that the `assignee` field has been filled in. ## Assigning multiple tasks to a user (to-many relationship) diff --git a/yarn.lock b/yarn.lock index b2b861ce0b6..5a5a09c5056 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1226,14 +1226,6 @@ core-js "^2.6.5" regenerator-runtime "^0.13.2" -"@babel/runtime-corejs2@^7.6.3": - version "7.8.0" - resolved "https://registry.yarnpkg.com/@babel/runtime-corejs2/-/runtime-corejs2-7.8.0.tgz#bdcd5d52c8c69ea4bd2dce62dc4fcad6159b2587" - integrity sha512-GsSb5MFUX2Q+zVUa7d4/8EXz7KPDuw0uNzY1sdBV7kvzdHzotxqi0HQlqesDRcffpvqcr+ZX8N+mMnb7/osExw== - dependencies: - core-js "^2.6.5" - regenerator-runtime "^0.13.2" - "@babel/runtime-corejs3@^7.7.4": version "7.7.7" resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.7.7.tgz#78fcbd472daec13abc42678bfc319e58a62235a3" @@ -6547,13 +6539,6 @@ css-blank-pseudo@^0.1.4: dependencies: postcss "^7.0.5" -css-box-model@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.0.tgz#3a26377b4162b3200d2ede4b064ec5b6a75186d0" - integrity sha512-lri0br+jSNV0kkkiGEp9y9y3Njq2PmpqbeGWRFQJuZteZzY9iC9GZhQ8Y4WpPwM/2YocjHePxy14igJY7YKzkA== - dependencies: - tiny-invariant "^1.0.6" - css-color-keywords@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" @@ -18039,11 +18024,6 @@ raf-schd@^4.0.0: resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.0.tgz#9855756c5045ff4ed4516e14a47719387c3c907b" integrity sha512-m7zq0JkIrECzw9mO5Zcq6jN4KayE34yoIS9hJoiZNXyOAT06PPA8PrR+WtJIeFW09YjUfNkMMN9lrmAt6BURCA== -raf-schd@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0" - integrity sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ== - ramda@0.24.1: version "0.24.1" resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.24.1.tgz#c3b7755197f35b8dc3502228262c4c91ddb6b857" @@ -18127,19 +18107,6 @@ react-apollo@^3.1.3: "@apollo/react-hooks" "^3.1.3" "@apollo/react-ssr" "^3.1.3" -react-beautiful-dnd@^12.2.0: - version "12.2.0" - resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-12.2.0.tgz#e5f6222f9e7934c6ed4ee09024547f9e353ae423" - integrity sha512-s5UrOXNDgeEC+sx65IgbeFlqKKgK3c0UfbrJLWufP34WBheyu5kJ741DtJbsSgPKyNLkqfswpMYr0P8lRj42cA== - dependencies: - "@babel/runtime-corejs2" "^7.6.3" - css-box-model "^1.2.0" - memoize-one "^5.1.1" - raf-schd "^4.0.2" - react-redux "^7.1.1" - redux "^4.0.4" - use-memo-one "^1.1.1" - react-codemirror2@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/react-codemirror2/-/react-codemirror2-6.0.0.tgz#180065df57a64026026cde569a9708fdf7656525" @@ -18292,11 +18259,6 @@ react-is@16.8.6, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@ resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16" integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA== -react-is@^16.9.0: - version "16.12.0" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" - integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== - react-lifecycles-compat@^3.0.0, react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" @@ -18385,18 +18347,6 @@ react-redux@^5.0.6: react-is "^16.6.0" react-lifecycles-compat "^3.0.0" -react-redux@^7.1.1: - version "7.1.3" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.1.3.tgz#717a3d7bbe3a1b2d535c94885ce04cdc5a33fc79" - integrity sha512-uI1wca+ECG9RoVkWQFF4jDMqmaw0/qnvaSvOoL/GA4dNxf6LoV8sUAcNDvE5NWKs4hFpn0t6wswNQnY3f7HT3w== - dependencies: - "@babel/runtime" "^7.5.5" - hoist-non-react-statics "^3.3.0" - invariant "^2.2.4" - loose-envify "^1.4.0" - prop-types "^15.7.2" - react-is "^16.9.0" - react-router-dom@5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.1.2.tgz#06701b834352f44d37fbb6311f870f84c76b9c18" @@ -18834,14 +18784,6 @@ redux@^4.0.0: loose-envify "^1.4.0" symbol-observable "^1.2.0" -redux@^4.0.4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" - integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== - dependencies: - loose-envify "^1.4.0" - symbol-observable "^1.2.0" - reflect.ownkeys@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460" @@ -21456,11 +21398,6 @@ tiny-invariant@^1.0.1, tiny-invariant@^1.0.2: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.3.tgz#91efaaa0269ccb6271f0296aeedb05fc3e067b7a" integrity sha512-ytQx8T4DL8PjlX53yYzcIC0WhIZbpR0p1qcYjw2pHu3w6UtgWwFJQ/02cnhOnBBhlFx/edUIfcagCaQSe3KMWg== -tiny-invariant@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73" - integrity sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA== - tiny-warning@^0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-0.0.3.tgz#1807eb4c5f81784a6354d58ea1d5024f18c6c81f" @@ -22369,11 +22306,6 @@ url@0.11.0, url@^0.11.0: punycode "1.3.2" querystring "0.2.0" -use-memo-one@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.1.tgz#39e6f08fe27e422a7d7b234b5f9056af313bd22c" - integrity sha512-oFfsyun+bP7RX8X2AskHNTxu+R3QdE/RC5IefMbqptmACAA/gfol1KDD5KRzPsGMa62sWxGZw+Ui43u6x4ddoQ== - use-subscription@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.1.1.tgz#5509363e9bb152c4fb334151d4dceb943beaa7bb" From 6371b021ee0b2022a3724992a6319bd0d7dd3583 Mon Sep 17 00:00:00 2001 From: Jess Telford Date: Tue, 14 Jan 2020 14:49:15 +1100 Subject: [PATCH 07/93] Correctly load Content field views when field access set to update: false (#2151) --- .changeset/swift-gorillas-switch.md | 5 + .../app-admin-ui/client/pages/Item/index.js | 138 +++++++++--------- 2 files changed, 75 insertions(+), 68 deletions(-) create mode 100644 .changeset/swift-gorillas-switch.md diff --git a/.changeset/swift-gorillas-switch.md b/.changeset/swift-gorillas-switch.md new file mode 100644 index 00000000000..5d970ad77db --- /dev/null +++ b/.changeset/swift-gorillas-switch.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/app-admin-ui': patch +--- + +Correctly load Content field views when field access set to update: false diff --git a/packages/app-admin-ui/client/pages/Item/index.js b/packages/app-admin-ui/client/pages/Item/index.js index 4a1d77cfc0a..0f0be0dc573 100644 --- a/packages/app-admin-ui/client/pages/Item/index.js +++ b/packages/app-admin-ui/client/pages/Item/index.js @@ -53,6 +53,12 @@ const getCurrentValues = memoizeOne(getValues); const deserializeItem = memoizeOne((list, data) => list.deserializeItemData(data)); +const getRenderableFields = memoizeOne(list => + list.fields + .filter(({ isPrimaryKey }) => !isPrimaryKey) + .filter(({ maybeAccess, config }) => !!maybeAccess.update || !!config.isReadOnly) +); + const ItemDetails = withRouter( class ItemDetails extends Component { constructor(props) { @@ -60,10 +66,8 @@ const ItemDetails = withRouter( // memoized function so we can call it multiple times _per component_ this.getFieldsObject = memoizeOne(() => arrayToObject( - props.list.fields - .filter(({ isPrimaryKey }) => !isPrimaryKey) - .filter(({ isReadOnly }) => !isReadOnly) - .filter(({ maybeAccess }) => !!maybeAccess.update), + // NOTE: We _exclude_ read only fields + getRenderableFields(props.list).filter(({ config }) => !config.isReadOnly), 'path' ) ); @@ -295,66 +299,61 @@ const ItemDetails = withRouter(
- {list.fields - .filter(({ isPrimaryKey }) => !isPrimaryKey) - .filter(({ maybeAccess, config }) => { - return !!maybeAccess.update || config.isReadOnly; - }) - .map((field, i) => ( - - {() => { - const [Field] = field.adminMeta.readViews([field.views.Field]); - - let onChange = useCallback( - value => { - this.setState(({ item: itm }) => ({ - item: { - ...itm, - [field.path]: value, - }, - validationErrors: {}, - validationWarnings: {}, - itemHasChanged: true, - })); - }, - [field] - ); - - return useMemo( - () => ( - - ), - [ - i, - field, - list, - itemErrors[field.path], - item[field.path], - item.id, - validationErrors[field.path], - validationWarnings[field.path], - savedData[field.path], - onChange, - ] - ); - }} - - ))} + {getRenderableFields(list).map((field, i) => ( + + {() => { + const [Field] = field.adminMeta.readViews([field.views.Field]); + + let onChange = useCallback( + value => { + this.setState(({ item: itm }) => ({ + item: { + ...itm, + [field.path]: value, + }, + validationErrors: {}, + validationWarnings: {}, + itemHasChanged: true, + })); + }, + [field] + ); + + return useMemo( + () => ( + + ), + [ + i, + field, + list, + itemErrors[field.path], + item[field.path], + item.id, + validationErrors[field.path], + validationWarnings[field.path], + savedData[field.path], + onChange, + ] + ); + }} + + ))}