diff --git a/.changeset/beige-dragons-jump.md b/.changeset/beige-dragons-jump.md new file mode 100644 index 00000000000..7df11bd3089 --- /dev/null +++ b/.changeset/beige-dragons-jump.md @@ -0,0 +1,7 @@ +--- +'@keystonejs/list-plugins': patch +--- + +Tweaked hooks and utility function. +* Renamed `composeResolveInput` utility function to `composeHook` to indicate right use by name, this can also be used in other hook type and not just `resolveInput` hook. +* Switch to use of `operation` param to hook for detecting if this is `create` or `update` operation instead of existingItem being `undefined`. \ No newline at end of file diff --git a/.changeset/breezy-files-peel.md b/.changeset/breezy-files-peel.md new file mode 100644 index 00000000000..5be6f1662b9 --- /dev/null +++ b/.changeset/breezy-files-peel.md @@ -0,0 +1,7 @@ +--- +'@keystonejs/app-admin-ui': minor +--- + +Refactored the internal handling of list data fetching. This resolves two issues: +- Fixed two API requests being made when loading a list. +- Fixed Ract errors in the search and pagination components. diff --git a/.changeset/bright-papayas-agree.md b/.changeset/bright-papayas-agree.md new file mode 100644 index 00000000000..04271cff1a7 --- /dev/null +++ b/.changeset/bright-papayas-agree.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/app-admin-ui': patch +--- + +Removed unused prop passed to `ItemDetails`. diff --git a/.changeset/chatty-garlics-applaud.md b/.changeset/chatty-garlics-applaud.md new file mode 100644 index 00000000000..50502558383 --- /dev/null +++ b/.changeset/chatty-garlics-applaud.md @@ -0,0 +1,6 @@ +--- +'@keystonejs/app-admin-ui': minor +'@keystonejs/fields': minor +--- + +The base `FieldController` class no longer takes the owning list as a second argument. diff --git a/.changeset/chilly-adults-search.md b/.changeset/chilly-adults-search.md new file mode 100644 index 00000000000..76c4e54e1a7 --- /dev/null +++ b/.changeset/chilly-adults-search.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/app-admin-ui': patch +--- + +Refactored `UpdateManyModal` component. diff --git a/.changeset/cuddly-llamas-explode.md b/.changeset/cuddly-llamas-explode.md new file mode 100644 index 00000000000..c7875adf093 --- /dev/null +++ b/.changeset/cuddly-llamas-explode.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/app-admin-ui': patch +--- + +Revert change in CSS import diff --git a/.changeset/curly-llamas-compare.md b/.changeset/curly-llamas-compare.md new file mode 100644 index 00000000000..78677d86dbb --- /dev/null +++ b/.changeset/curly-llamas-compare.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/app-admin-ui': patch +--- + +Cleaned up duplicated and/or unnecessary list config data. diff --git a/.changeset/curly-pumpkins-call.md b/.changeset/curly-pumpkins-call.md new file mode 100644 index 00000000000..e4c3b2962f1 --- /dev/null +++ b/.changeset/curly-pumpkins-call.md @@ -0,0 +1,6 @@ +--- +'@keystonejs/app-admin-ui': major +'@keystonejs/keystone': major +--- + +Added a method `Keystone.getAdminViews({ schemaName })` which returns the views for the Admin UI. `List.getAdminMeta()` no longer returns a `views` values. diff --git a/.changeset/dirty-cougars-smoke.md b/.changeset/dirty-cougars-smoke.md new file mode 100644 index 00000000000..2934da7147d --- /dev/null +++ b/.changeset/dirty-cougars-smoke.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/app-admin-ui': patch +--- + +Converted ResizeHandler and ScrollQuery components to custom hooks. diff --git a/.changeset/eighty-boxes-remember.md b/.changeset/eighty-boxes-remember.md new file mode 100644 index 00000000000..447e58be53a --- /dev/null +++ b/.changeset/eighty-boxes-remember.md @@ -0,0 +1,6 @@ +--- +'@keystonejs/keystone': patch +'@keystonejs/utils': patch +--- + +Converted some stray promise chains to async/await. diff --git a/.changeset/few-moles-talk.md b/.changeset/few-moles-talk.md new file mode 100644 index 00000000000..85dc11f561c --- /dev/null +++ b/.changeset/few-moles-talk.md @@ -0,0 +1,6 @@ +--- +'@keystonejs/app-admin-ui': minor +'@keystonejs/fields': minor +--- + +Refactored the Unsplash content block to use Apollo query hooks. diff --git a/.changeset/fuzzy-carrots-battle.md b/.changeset/fuzzy-carrots-battle.md new file mode 100644 index 00000000000..251e2c8253d --- /dev/null +++ b/.changeset/fuzzy-carrots-battle.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/keystone': patch +--- + +Included id fields in the \_ksListsMeta schema query. diff --git a/.changeset/giant-hornets-argue.md b/.changeset/giant-hornets-argue.md new file mode 100644 index 00000000000..c074cb97cf5 --- /dev/null +++ b/.changeset/giant-hornets-argue.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/app-admin-ui': patch +--- + +Refactored internals for readability. diff --git a/.changeset/gorgeous-cheetahs-roll.md b/.changeset/gorgeous-cheetahs-roll.md new file mode 100644 index 00000000000..c0a427040d7 --- /dev/null +++ b/.changeset/gorgeous-cheetahs-roll.md @@ -0,0 +1,9 @@ +--- +'@keystonejs/app-admin-ui': major +'@keystonejs/field-views-loader': major +--- + +The default function in `@keystonejs/field-views-loader` now takes `{ pages, hooks, listViews }` rather than `{ adminMeta }`. +`AdminUIApp` now has a method `.getAdminViews({ keystone, includeLists })` which returns these values. +`AdminUIApp.createDevMiddleware` now takes `{ adminMeta, keystone }` as arguments. +These changes will only effect users who may have explicitly been using the `@keystone/fields-views-loader` packages or `.createDevMiddleware()`. diff --git a/.changeset/gorgeous-lemons-peel.md b/.changeset/gorgeous-lemons-peel.md new file mode 100644 index 00000000000..b02a2eea334 --- /dev/null +++ b/.changeset/gorgeous-lemons-peel.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/app-admin-ui': major +--- + +Removed `AdminUIApp.createSessionMiddleware()`. No need to take an action if you were explicitly using this method. diff --git a/.changeset/large-geese-listen.md b/.changeset/large-geese-listen.md new file mode 100644 index 00000000000..dcde46e55d9 --- /dev/null +++ b/.changeset/large-geese-listen.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/app-admin-ui': patch +--- + +Refactored internals of AdminUIApp, no functional changes. diff --git a/.changeset/late-countries-yell.md b/.changeset/late-countries-yell.md new file mode 100644 index 00000000000..6bb393480da --- /dev/null +++ b/.changeset/late-countries-yell.md @@ -0,0 +1,7 @@ +--- +'@keystonejs/app-admin-ui': patch +'@keystonejs/auth-password': patch +'@keystonejs/keystone': patch +--- + +Fixed Admin UI sometimes using the wrong auth mutation name. diff --git a/.changeset/light-tips-train.md b/.changeset/light-tips-train.md new file mode 100644 index 00000000000..dc6e3d96615 --- /dev/null +++ b/.changeset/light-tips-train.md @@ -0,0 +1,10 @@ +--- +'@keystonejs/keystone': major +--- + +Fixed several access control input issues: +- `itemIds` is now properly set in list-level updateMany mutation checks. Previously this data was incorrectly assigned to `itemId` which is now `undefined` in list-level checks. +- `itemIds` is now set in field-level updateMany mutation checks (previously `undefined`). +- `itemId` is now set in field-level updateMany mutation checks (previously `undefined`). This is the ID of the item currently being checked. +- `itemId` is now properly set in field-level updateSingle mutation checks (previously `undefined`). +- All field-level access control checks now have `gqlName` properly set (previously `undefined`). diff --git a/.changeset/long-adults-travel.md b/.changeset/long-adults-travel.md new file mode 100644 index 00000000000..128894971d5 --- /dev/null +++ b/.changeset/long-adults-travel.md @@ -0,0 +1,10 @@ +--- +'@keystonejs/cypress-project-access-control': patch +'@keystonejs/cypress-project-basic': patch +'@keystonejs/cypress-project-client-validation': patch +'@keystonejs/cypress-project-login': patch +'@keystonejs/keystone': major +'@keystonejs/session': major +--- + +The `cookieSecret` option no longer defaults to a static value. It is now required in production mode. In development mode, if undefined, a random new value is generated each time the server is started. diff --git a/.changeset/poor-needles-know.md b/.changeset/poor-needles-know.md new file mode 100644 index 00000000000..9d4a9a11cee --- /dev/null +++ b/.changeset/poor-needles-know.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/test-utils': patch +--- + +Explicitly set `cookieSecret` in `Keystone` objects to prevent warnings. diff --git a/.changeset/popular-lemons-cross.md b/.changeset/popular-lemons-cross.md new file mode 100644 index 00000000000..39bf25db785 --- /dev/null +++ b/.changeset/popular-lemons-cross.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/app-admin-ui': patch +--- + +Internal refactor, no functional changes. diff --git a/.changeset/purple-cherries-wave.md b/.changeset/purple-cherries-wave.md new file mode 100644 index 00000000000..7c03db6fa8b --- /dev/null +++ b/.changeset/purple-cherries-wave.md @@ -0,0 +1,5 @@ +--- +"@keystonejs/fields-datetime-utc": patch +--- + +Fixed an issue with filtering by null values. diff --git a/.changeset/purple-poets-heal.md b/.changeset/purple-poets-heal.md new file mode 100644 index 00000000000..8287b71ba20 --- /dev/null +++ b/.changeset/purple-poets-heal.md @@ -0,0 +1,29 @@ +--- +'@keystonejs/keystone': major +'@keystonejs/session': major +--- + +Moved the cookie configuration from individual options to an object which is passed directly to the express-session middleware. +Previously you could only set `secure` and `maxAge` via `secureCookies` and `cookieMaxAge`. +These options have been removed. +You can now set a config option called `cookie` which can contain `secure` and `maxAge`, as well as `domain`, `expires`, `httpOnly`, `path` and `sameSite`. + +The `sameSite` option is now explicitly defaulted to `false`. + +See the [express-session middleware docs](https://github.com/expressjs/session#cookie) for more details on these options.. + + #### Default + + ```javascript + const keystone = new Keystone({ + cookie: { + // domain: undefined, + // expires: undefined, + // httpOnly: true, + maxAge: 1000 * 60 * 60 * 24 * 30, // 30 days + sameSite: false, + // path: '/', + secure: process.env.NODE_ENV === 'production', // Defaults to true in production + }, + }); + ``` diff --git a/.changeset/rotten-berries-double.md b/.changeset/rotten-berries-double.md deleted file mode 100644 index 14e09d179ba..00000000000 --- a/.changeset/rotten-berries-double.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@keystonejs/app-admin-ui': patch ---- - -Don't duplicate HeaderInset multiple times. diff --git a/.changeset/sixty-snakes-rhyme.md b/.changeset/sixty-snakes-rhyme.md deleted file mode 100644 index 22b0704ef26..00000000000 --- a/.changeset/sixty-snakes-rhyme.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@keystonejs/app-admin-ui': patch -'@keystonejs/keystone': patch ---- - -Fixed list-level `adminDoc` not doing anything. diff --git a/.changeset/smart-deers-worry.md b/.changeset/smart-deers-worry.md new file mode 100644 index 00000000000..87fd71450ba --- /dev/null +++ b/.changeset/smart-deers-worry.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/demo-custom-fields': patch +--- + +Used new FieldDescription syntax. diff --git a/.changeset/soft-dogs-rest.md b/.changeset/soft-dogs-rest.md new file mode 100644 index 00000000000..335c6dddb28 --- /dev/null +++ b/.changeset/soft-dogs-rest.md @@ -0,0 +1,7 @@ +--- +'@keystonejs/fields': minor +'@keystonejs/demo-custom-fields': patch +'@keystonejs/app-admin-ui': patch +--- + +Elevated isOrderable, isRequired, and adminDoc keys to direct FieldController properties. diff --git a/.changeset/spicy-apples-jump.md b/.changeset/spicy-apples-jump.md new file mode 100644 index 00000000000..98203d52cee --- /dev/null +++ b/.changeset/spicy-apples-jump.md @@ -0,0 +1,7 @@ +--- +'@keystonejs/demo-custom-fields': patch +'@keystonejs/fields': patch +'@keystonejs/fields-mongoid': patch +--- + +Fixed a bunch more duplicate field class names (no functional changes). diff --git a/.changeset/stale-parents-peel.md b/.changeset/stale-parents-peel.md new file mode 100644 index 00000000000..24ae1fa7d88 --- /dev/null +++ b/.changeset/stale-parents-peel.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/fields': patch +--- + +Updated views to make use of the `getRefList()` method of the relationship field controller. diff --git a/.changeset/tasty-numbers-turn.md b/.changeset/tasty-numbers-turn.md new file mode 100644 index 00000000000..d98a611eaa1 --- /dev/null +++ b/.changeset/tasty-numbers-turn.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/app-admin-ui': major +--- + +Removed the method `AdminUIApp.getAdminMeta()` in favour of the more complete `AdminUIApp.getAdminUIMeta(keystone)`. diff --git a/.changeset/twenty-horses-tease.md b/.changeset/twenty-horses-tease.md new file mode 100644 index 00000000000..98c293c939a --- /dev/null +++ b/.changeset/twenty-horses-tease.md @@ -0,0 +1,7 @@ +--- +'@keystonejs/adapter-mongoose': patch +'@keystonejs/fields': patch +'@keystonejs/mongo-join-builder': patch +--- + +Updated mongodb and mongoose dependencies to latest version. diff --git a/.changeset/witty-dodos-film.md b/.changeset/witty-dodos-film.md new file mode 100644 index 00000000000..3e2ca8855d4 --- /dev/null +++ b/.changeset/witty-dodos-film.md @@ -0,0 +1,6 @@ +--- +'@arch-ui/options': patch +'@keystonejs/fields': patch +--- + +Added horizontal padding to Select field filter dropdown. diff --git a/api-tests/CHANGELOG.md b/api-tests/CHANGELOG.md index fb0456a14de..1c76c7f317e 100644 --- a/api-tests/CHANGELOG.md +++ b/api-tests/CHANGELOG.md @@ -1,5 +1,19 @@ # @keystonejs/api-tests +## 5.4.2 + +### Patch Changes + +- [`f266a692`](https://github.com/keystonejs/keystone/commit/f266a6923a24c84936d66e00ec7de0ea0956445b) [#2854](https://github.com/keystonejs/keystone/pull/2854) Thanks [@timleslie](https://github.com/timleslie)! - Upgraded dependencies. + +* [`96f0c6e9`](https://github.com/keystonejs/keystone/commit/96f0c6e917ecdd02af8da52829608b003219d3ca) [#2845](https://github.com/keystonejs/keystone/pull/2845) Thanks [@timleslie](https://github.com/timleslie)! - Updated patch versions of dependencies. + +* Updated dependencies [[`b897ba14`](https://github.com/keystonejs/keystone/commit/b897ba14e34aa441b2d658c30b3dda9d1ebd48e2), [`f266a692`](https://github.com/keystonejs/keystone/commit/f266a6923a24c84936d66e00ec7de0ea0956445b), [`4e56eed6`](https://github.com/keystonejs/keystone/commit/4e56eed68c643fd436c371e2635d3024c51968b0), [`8a135a88`](https://github.com/keystonejs/keystone/commit/8a135a88ae6f3a4434db0ba7033cad2e5f18651e), [`3d40bd7d`](https://github.com/keystonejs/keystone/commit/3d40bd7dd39f2b5589012356dd2b1698eda4f0b2), [`96f0c6e9`](https://github.com/keystonejs/keystone/commit/96f0c6e917ecdd02af8da52829608b003219d3ca)]: + - @keystonejs/fields@9.0.5 + - @keystonejs/app-graphql@5.1.6 + - @keystonejs/keystone@8.1.4 + - @keystonejs/test-utils@6.1.1 + ## 5.4.1 ### Patch Changes diff --git a/api-tests/package.json b/api-tests/package.json index a713a4285dc..69959d0976b 100644 --- a/api-tests/package.json +++ b/api-tests/package.json @@ -2,7 +2,7 @@ "name": "@keystonejs/api-tests", "description": "A set of tests for running against the KeystoneJS API.", "private": true, - "version": "5.4.1", + "version": "5.4.2", "author": "The KeystoneJS Development Team", "license": "MIT", "engines": { @@ -19,15 +19,15 @@ "body-parser": "^1.18.2", "cookie-signature": "^1.1.0", "globby": "^11.0.0", - "supertest-light": "^1.0.2", + "supertest-light": "^1.0.3", "testcheck": "^1.0.0-rc.2" }, "dependencies": { "@keystonejs/auth-password": "^5.1.1", - "@keystonejs/fields": "^9.0.4", - "@keystonejs/test-utils": "^6.1.0", + "@keystonejs/fields": "^9.0.5", + "@keystonejs/test-utils": "^6.1.1", "@keystonejs/utils": "^5.4.0", - "cuid": "^2.1.6", + "cuid": "^2.1.8", "express": "^4.17.1" } } diff --git a/api-tests/queries/meta.test.js b/api-tests/queries/meta.test.js index 5406ddb8bdd..7e7bd40cb46 100644 --- a/api-tests/queries/meta.test.js +++ b/api-tests/queries/meta.test.js @@ -136,6 +136,12 @@ multiAdapterRunners().map(({ runner, adapterName }) => schema: { queries: ['User', 'allUsers', '_allUsersMeta'], fields: [ + { + name: 'id', + type: expect.stringMatching( + /MongoIdImplementation|AutoIncrementImplementation/ + ), + }, { name: 'company', type: 'Relationship', @@ -164,6 +170,12 @@ multiAdapterRunners().map(({ runner, adapterName }) => type: 'Company', queries: ['Company', 'allCompanies', '_allCompaniesMeta'], fields: [ + { + name: 'id', + type: expect.stringMatching( + /MongoIdImplementation|AutoIncrementImplementation/ + ), + }, { name: 'name', type: 'Text', @@ -186,6 +198,12 @@ multiAdapterRunners().map(({ runner, adapterName }) => schema: { queries: ['Post', 'allPosts', '_allPostsMeta'], fields: [ + { + name: 'id', + type: expect.stringMatching( + /MongoIdImplementation|AutoIncrementImplementation/ + ), + }, { name: 'content', type: 'Text', @@ -237,6 +255,12 @@ multiAdapterRunners().map(({ runner, adapterName }) => schema: { queries: ['User', 'allUsers', '_allUsersMeta'], fields: [ + { + name: 'id', + type: expect.stringMatching( + /MongoIdImplementation|AutoIncrementImplementation/ + ), + }, { name: 'company', type: 'Relationship', @@ -264,7 +288,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => ); test( - 'returns results for one list and one type of fields', + 'returns results for one list and one type of field', runner(setupKeystone, async ({ keystone }) => { const { data, errors } = await graphqlRequest({ keystone, diff --git a/benchmarks/CHANGELOG.md b/benchmarks/CHANGELOG.md index c1121c53423..efd417cab67 100644 --- a/benchmarks/CHANGELOG.md +++ b/benchmarks/CHANGELOG.md @@ -1,5 +1,19 @@ # @keystonejs/benchmarks +## 5.1.7 + +### Patch Changes + +- [`f266a692`](https://github.com/keystonejs/keystone/commit/f266a6923a24c84936d66e00ec7de0ea0956445b) [#2854](https://github.com/keystonejs/keystone/pull/2854) Thanks [@timleslie](https://github.com/timleslie)! - Upgraded dependencies. + +* [`96f0c6e9`](https://github.com/keystonejs/keystone/commit/96f0c6e917ecdd02af8da52829608b003219d3ca) [#2845](https://github.com/keystonejs/keystone/pull/2845) Thanks [@timleslie](https://github.com/timleslie)! - Updated patch versions of dependencies. + +* Updated dependencies [[`b897ba14`](https://github.com/keystonejs/keystone/commit/b897ba14e34aa441b2d658c30b3dda9d1ebd48e2), [`f266a692`](https://github.com/keystonejs/keystone/commit/f266a6923a24c84936d66e00ec7de0ea0956445b), [`4e56eed6`](https://github.com/keystonejs/keystone/commit/4e56eed68c643fd436c371e2635d3024c51968b0), [`8a135a88`](https://github.com/keystonejs/keystone/commit/8a135a88ae6f3a4434db0ba7033cad2e5f18651e), [`3d40bd7d`](https://github.com/keystonejs/keystone/commit/3d40bd7dd39f2b5589012356dd2b1698eda4f0b2), [`96f0c6e9`](https://github.com/keystonejs/keystone/commit/96f0c6e917ecdd02af8da52829608b003219d3ca)]: + - @keystonejs/fields@9.0.5 + - @keystonejs/app-graphql@5.1.6 + - @keystonejs/keystone@8.1.4 + - @keystonejs/test-utils@6.1.1 + ## 5.1.6 ### Patch Changes diff --git a/benchmarks/package.json b/benchmarks/package.json index 57c4f009230..4a5d3d89440 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -2,7 +2,7 @@ "name": "@keystonejs/benchmarks", "description": "A set of benchmarks for running against the KeystoneJS API.", "private": true, - "version": "5.1.6", + "version": "5.1.7", "author": "The KeystoneJS Development Team", "license": "MIT", "engines": { @@ -16,15 +16,15 @@ "dependencies": { "@keystonejs/adapter-knex": "^9.0.0", "@keystonejs/adapter-mongoose": "^8.0.0", - "@keystonejs/app-graphql": "^5.1.5", - "@keystonejs/fields": "^9.0.0", - "@keystonejs/keystone": "^8.0.0", + "@keystonejs/app-graphql": "^5.1.6", + "@keystonejs/fields": "^9.0.5", + "@keystonejs/keystone": "^8.1.4", "@keystonejs/session": "^6.0.1", - "@keystonejs/test-utils": "^6.0.3", + "@keystonejs/test-utils": "^6.1.1", "body-parser": "^1.18.2", "cookie-signature": "^1.1.0", - "cuid": "^2.1.6", - "supertest-light": "^1.0.2", + "cuid": "^2.1.8", + "supertest-light": "^1.0.3", "testcheck": "^1.0.0-rc.2" } } diff --git a/demo-projects/custom-fields/fields/MultiCheck/views/Field.js b/demo-projects/custom-fields/fields/MultiCheck/views/Field.js index 1cd217af620..1f44f119bb4 100644 --- a/demo-projects/custom-fields/fields/MultiCheck/views/Field.js +++ b/demo-projects/custom-fields/fields/MultiCheck/views/Field.js @@ -8,7 +8,7 @@ import { ShieldIcon } from '@arch-ui/icons'; import { Lozenge } from '@arch-ui/lozenge'; import { colors, gridSize } from '@arch-ui/theme'; -const TextField = ({ onChange, autoFocus, field, value, errors }) => { +const MultiCheckField = ({ onChange, autoFocus, field, value, errors }) => { const initialState = value ? value : field.config.defaultValue; const [values, setValues] = useState(initialState); useEffect(() => { @@ -41,8 +41,8 @@ const TextField = ({ onChange, autoFocus, field, value, errors }) => { {accessError ? ( ) : null} - {field.config.isRequired ? Required : null} - {field.config.adminDoc && {field.config.adminDoc}} + {field.isRequired ? Required : null} +
{field.config.options.map(label => ( { ); }; -export default TextField; +export default MultiCheckField; diff --git a/demo-projects/meetup/CHANGELOG.md b/demo-projects/meetup/CHANGELOG.md index a0f78eb877f..f2ff12c969d 100644 --- a/demo-projects/meetup/CHANGELOG.md +++ b/demo-projects/meetup/CHANGELOG.md @@ -1,5 +1,19 @@ # @keystonejs/demo-project-meetup +## 5.1.11 + +### Patch Changes + +- [`c68aed4a`](https://github.com/keystonejs/keystone/commit/c68aed4a414f5188f5dc9e99ac51d1afefc22e64) [#2846](https://github.com/keystonejs/keystone/pull/2846) Thanks [@timleslie](https://github.com/timleslie)! - Upgraded `uuid` from `3.x` to `7.x`. + +- Updated dependencies [[`ab484f19`](https://github.com/keystonejs/keystone/commit/ab484f195752bb3ec59f6beb7d8817dce610ad06), [`1b059e72`](https://github.com/keystonejs/keystone/commit/1b059e726d95bbc6ad09a76ed3b40dbc4cf11682), [`95babf5d`](https://github.com/keystonejs/keystone/commit/95babf5da8488f2d7f8ab9f91ff640576462af6d), [`4af9e407`](https://github.com/keystonejs/keystone/commit/4af9e4075c9329ab27e7aa18a664d2f2bcc1ac2d), [`04ec9981`](https://github.com/keystonejs/keystone/commit/04ec998166a8b3044570769a8c3f501d80527bf9), [`b897ba14`](https://github.com/keystonejs/keystone/commit/b897ba14e34aa441b2d658c30b3dda9d1ebd48e2), [`0aac3b41`](https://github.com/keystonejs/keystone/commit/0aac3b411a9e4f397645d9641c4675eab7a6e55b), [`b0bfcf79`](https://github.com/keystonejs/keystone/commit/b0bfcf79477249f3c0bb14db68588d84a68f0186), [`f266a692`](https://github.com/keystonejs/keystone/commit/f266a6923a24c84936d66e00ec7de0ea0956445b), [`4e56eed6`](https://github.com/keystonejs/keystone/commit/4e56eed68c643fd436c371e2635d3024c51968b0), [`8a135a88`](https://github.com/keystonejs/keystone/commit/8a135a88ae6f3a4434db0ba7033cad2e5f18651e), [`63a2f7c3`](https://github.com/keystonejs/keystone/commit/63a2f7c31777d968bad32d6e746e2f960c6ef0ad), [`96f0c6e9`](https://github.com/keystonejs/keystone/commit/96f0c6e917ecdd02af8da52829608b003219d3ca), [`81a9aa7c`](https://github.com/keystonejs/keystone/commit/81a9aa7c2349f9bb71d1a9686e4fa359a14b033f)]: + - @keystonejs/app-admin-ui@5.12.0 + - @keystonejs/file-adapters@6.0.2 + - @keystonejs/fields@9.0.5 + - @keystonejs/app-graphql@5.1.6 + - @keystonejs/fields-wysiwyg-tinymce@5.2.6 + - @keystonejs/keystone@8.1.4 + ## 5.1.10 ### Patch Changes diff --git a/demo-projects/meetup/package.json b/demo-projects/meetup/package.json index a4b60a35e04..2d91bddf272 100644 --- a/demo-projects/meetup/package.json +++ b/demo-projects/meetup/package.json @@ -2,7 +2,7 @@ "name": "@keystonejs/demo-project-meetup", "description": "An example KeystoneJS project showcasing a Meetup Site.", "private": true, - "version": "5.1.10", + "version": "5.1.11", "author": "The KeystoneJS Development Team", "license": "MIT", "engines": { @@ -18,15 +18,15 @@ "@apollo/react-ssr": "^3.1.3", "@emotion/core": "^10.0.28", "@keystonejs/adapter-mongoose": "^8.0.0", - "@keystonejs/app-admin-ui": "^5.11.1", - "@keystonejs/app-graphql": "^5.1.5", + "@keystonejs/app-admin-ui": "^5.12.0", + "@keystonejs/app-graphql": "^5.1.6", "@keystonejs/app-next": "^5.1.2", "@keystonejs/auth-password": "^5.1.6", "@keystonejs/email": "^5.1.4", - "@keystonejs/fields": "^9.0.4", - "@keystonejs/fields-wysiwyg-tinymce": "^5.2.5", - "@keystonejs/file-adapters": "^6.0.1", - "@keystonejs/keystone": "^8.1.2", + "@keystonejs/fields": "^9.0.5", + "@keystonejs/fields-wysiwyg-tinymce": "^5.2.6", + "@keystonejs/file-adapters": "^6.0.2", + "@keystonejs/keystone": "^8.1.4", "@keystonejs/session": "^6.0.1", "apollo-cache-inmemory": "^1.6.5", "apollo-client": "^2.6.8", @@ -48,7 +48,7 @@ "react-dom": "^16.13.1", "react-toast-notifications": "^2.3.0", "react-use-form-state": "^0.12.0", - "uuid": "^3.3.2" + "uuid": "^7.0.3" }, "repository": "https://github.com/keystonejs/keystone/tree/master/demo-projects/meetup" } diff --git a/demo-projects/meetup/schema.js b/demo-projects/meetup/schema.js index 0924cf876da..5be653109a2 100644 --- a/demo-projects/meetup/schema.js +++ b/demo-projects/meetup/schema.js @@ -1,5 +1,5 @@ require('dotenv').config(); -const uuid = require('uuid/v4'); +const { v4: uuid } = require('uuid'); const { sendEmail } = require('./emails'); const { diff --git a/docs/guides/heroku.md b/docs/guides/heroku.md index 4f909346a30..5894bcba049 100644 --- a/docs/guides/heroku.md +++ b/docs/guides/heroku.md @@ -112,7 +112,9 @@ To use secure cookies we need to add below to index.js. ```js const keystone = new Keystone({ ... - secureCookies: true, + cookie: { + secure: true, + }, cookieSecret: 'very-secret' }); diff --git a/docs/guides/production.md b/docs/guides/production.md index 4a4faadab22..c6f7e8f11ad 100644 --- a/docs/guides/production.md +++ b/docs/guides/production.md @@ -10,7 +10,17 @@ Yes, Keystone can be (and is!) used for production websites. Here's a handy list ## Secure cookies -In production builds, [Keystone's `secureCookies`](/packages/keystone/README.md#config) defaults to true. Make sure your server is HTTPS-enabled when `secureCookies` is enabled or you will be unable to log in. +In production builds, [Keystone's `cookie` object](/packages/keystone/README.md#config) defaults to + +```js +cookie = { + secure: process.env.NODE_ENV === 'production', // Defaults to true in production + maxAge: 1000 * 60 * 60 * 24 * 30, // 30 days + sameSite: false, +}; +``` + +Make sure your server is HTTPS-enabled when `secure` is enabled or you will be unable to log in. ## Session handling diff --git a/package.json b/package.json index 4ccac7a6426..14e34dd940d 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "mocha": "^7.0.1", "mocha-junit-reporter": "^1.21.0", "moment": "^2.24.0", - "mongodb": "^3.5.5", + "mongodb": "^3.5.7", "p-is-promise": "^3.0.0", "pino-colada": "^1.4.5", "prettier": "^1.19.1", @@ -105,22 +105,22 @@ "remark-cli": "^6.0.1", "remark-frontmatter": "^1.3.1", "remark-toc": "^5.1.1", - "rimraf": "^3.0.0", + "rimraf": "^3.0.2", "split": "^1.0.1", - "stack-utils": "^1.0.2", + "stack-utils": "^2.0.2", "start-server-and-test": "^1.10.6", - "supertest-light": "^1.0.2", + "supertest-light": "^1.0.3", "terminal-link": "^1.3.0", "terminal-link-cli": "^2.0.0", "terser": "^3.14.1", "testcheck": "^1.0.0-rc.2", - "tinymce": "^5.2.0", - "tmp": "^0.1.0", + "tinymce": "^5.2.2", + "tmp": "^0.2.0", "to-pascal-case": "^1.0.0", "underscore.string": "^3.3.5", - "unist-util-visit": "^1.4.0", + "unist-util-visit": "^2.0.2", "unsplash-js": "^6.0.0", - "uuid": "^3.3.2", + "uuid": "^7.0.3", "webpack": "4.41.6" }, "prettier": { diff --git a/packages/adapter-mongoose/package.json b/packages/adapter-mongoose/package.json index 13961209927..36b80590caf 100644 --- a/packages/adapter-mongoose/package.json +++ b/packages/adapter-mongoose/package.json @@ -14,7 +14,7 @@ "@keystonejs/mongo-join-builder": "^7.0.0", "@keystonejs/utils": "^5.4.0", "@sindresorhus/slugify": "^0.11.0", - "mongoose": "^5.9.5", + "mongoose": "^5.9.11", "p-settle": "^3.1.0", "pluralize": "^7.0.0" }, diff --git a/packages/app-admin-ui/CHANGELOG.md b/packages/app-admin-ui/CHANGELOG.md index eb63802cc42..3ac0ceab414 100644 --- a/packages/app-admin-ui/CHANGELOG.md +++ b/packages/app-admin-ui/CHANGELOG.md @@ -1,5 +1,39 @@ # @keystonejs/app-admin-ui +## 5.12.0 + +### Minor Changes + +- [`95babf5d`](https://github.com/keystonejs/keystone/commit/95babf5da8488f2d7f8ab9f91ff640576462af6d) [#2798](https://github.com/keystonejs/keystone/pull/2798) Thanks [@Vultraz](https://github.com/Vultraz)! - Revamped sidebar design. + +### Patch Changes + +- [`ab484f19`](https://github.com/keystonejs/keystone/commit/ab484f195752bb3ec59f6beb7d8817dce610ad06) [#2440](https://github.com/keystonejs/keystone/pull/2440) Thanks [@gautamsi](https://github.com/gautamsi)! - Enabled selection of multiple options in Filter for Select type fields. This also disables use of filter with empty values, you can not apply new filter if none of the options are selected. Can not deselect last filter item when adding or editing. + +* [`1b059e72`](https://github.com/keystonejs/keystone/commit/1b059e726d95bbc6ad09a76ed3b40dbc4cf11682) [#2810](https://github.com/keystonejs/keystone/pull/2810) Thanks [@Vultraz](https://github.com/Vultraz)! - Clarified functionality of item view 'Back' button. + +- [`4af9e407`](https://github.com/keystonejs/keystone/commit/4af9e4075c9329ab27e7aa18a664d2f2bcc1ac2d) [#2864](https://github.com/keystonejs/keystone/pull/2864) Thanks [@molomby](https://github.com/molomby)! - Static assets loaded for the Admin UI (eg. JS bundles) now have correct Content-Type response headers + +* [`0aac3b41`](https://github.com/keystonejs/keystone/commit/0aac3b411a9e4f397645d9641c4675eab7a6e55b) [#2860](https://github.com/keystonejs/keystone/pull/2860) Thanks [@Nikodermus](https://github.com/Nikodermus)! - Updated Webpack config so stylesheets imported from node_modules are included in the production build. + +- [`b0bfcf79`](https://github.com/keystonejs/keystone/commit/b0bfcf79477249f3c0bb14db68588d84a68f0186) [#2812](https://github.com/keystonejs/keystone/pull/2812) Thanks [@Vultraz](https://github.com/Vultraz)! - Don't duplicate HeaderInset multiple times. + +* [`8a135a88`](https://github.com/keystonejs/keystone/commit/8a135a88ae6f3a4434db0ba7033cad2e5f18651e) [#2808](https://github.com/keystonejs/keystone/pull/2808) Thanks [@Vultraz](https://github.com/Vultraz)! - Fixed list-level `adminDoc` not doing anything. + +- [`63a2f7c3`](https://github.com/keystonejs/keystone/commit/63a2f7c31777d968bad32d6e746e2f960c6ef0ad) [#2816](https://github.com/keystonejs/keystone/pull/2816) Thanks [@Vultraz](https://github.com/Vultraz)! - Fixed Success toast showing stale data when changing an item's name. + +* [`96f0c6e9`](https://github.com/keystonejs/keystone/commit/96f0c6e917ecdd02af8da52829608b003219d3ca) [#2845](https://github.com/keystonejs/keystone/pull/2845) Thanks [@timleslie](https://github.com/timleslie)! - Updated patch versions of dependencies. + +- [`81a9aa7c`](https://github.com/keystonejs/keystone/commit/81a9aa7c2349f9bb71d1a9686e4fa359a14b033f) [#2856](https://github.com/keystonejs/keystone/pull/2856) Thanks [@timleslie](https://github.com/timleslie)! - Added missing peer dependencies. + +- Updated dependencies [[`95babf5d`](https://github.com/keystonejs/keystone/commit/95babf5da8488f2d7f8ab9f91ff640576462af6d), [`45b151b0`](https://github.com/keystonejs/keystone/commit/45b151b05de0583ba50364caeda8b5bb7a111385), [`b897ba14`](https://github.com/keystonejs/keystone/commit/b897ba14e34aa441b2d658c30b3dda9d1ebd48e2), [`f266a692`](https://github.com/keystonejs/keystone/commit/f266a6923a24c84936d66e00ec7de0ea0956445b), [`4e56eed6`](https://github.com/keystonejs/keystone/commit/4e56eed68c643fd436c371e2635d3024c51968b0), [`96f0c6e9`](https://github.com/keystonejs/keystone/commit/96f0c6e917ecdd02af8da52829608b003219d3ca)]: + - @arch-ui/navbar@0.1.10 + - @arch-ui/badge@0.0.16 + - @arch-ui/confirm@0.0.19 + - @arch-ui/dialog@0.0.21 + - @keystonejs/fields@9.0.5 + - @keystonejs/build-field-types@5.2.6 + ## 5.11.1 ### Patch Changes diff --git a/packages/app-admin-ui/client/classes/List.js b/packages/app-admin-ui/client/classes/List.js index 7fdfa4505ad..9e979a5d19b 100644 --- a/packages/app-admin-ui/client/classes/List.js +++ b/packages/app-admin-ui/client/classes/List.js @@ -7,16 +7,24 @@ export const gqlCountQueries = lists => gql`{ }`; export default class List { - constructor(config, adminMeta, views) { - this.config = config; - this.adminMeta = adminMeta; + constructor( + { access, adminConfig, adminDoc, fields, gqlNames, key, label, path, plural, singular }, + adminMeta, + views + ) { + this.access = access; + this.adminConfig = adminConfig; + this.adminDoc = adminDoc; + this.gqlNames = gqlNames; + this.key = key; + this.label = label; + this.path = path; + this.plural = plural; + this.singular = singular; - // TODO: undo this - Object.assign(this, config); - - this.fields = config.fields.map(fieldConfig => { + this.fields = fields.map(fieldConfig => { const [Controller] = adminMeta.readViews([views[fieldConfig.path].Controller]); - return new Controller(fieldConfig, this, adminMeta, views[fieldConfig.path]); + return new Controller(fieldConfig, adminMeta, views[fieldConfig.path]); }); this._fieldsByPath = arrayToObject(this.fields, 'path'); @@ -29,31 +37,31 @@ export default class List { } } `; + this.createManyMutation = gql` - mutation createMany($data: ${this.gqlNames.createManyInputName}!) { - ${this.gqlNames.createManyMutationName}(data: $data) { - id + mutation createMany($data: ${this.gqlNames.createManyInputName}!) { + ${this.gqlNames.createManyMutationName}(data: $data) { + id + } } - } - `; + `; + this.updateMutation = gql` - mutation update( - $id: ID!, - $data: ${this.gqlNames.updateInputName}) - { + mutation update($id: ID!, $data: ${this.gqlNames.updateInputName}) { ${this.gqlNames.updateMutationName}(id: $id, data: $data) { id } } `; + this.updateManyMutation = gql` - mutation updateMany($data: [${this.gqlNames.updateManyInputName}]) - { + mutation updateMany($data: [${this.gqlNames.updateManyInputName}]) { ${this.gqlNames.updateManyMutationName}(data: $data) { id } } `; + this.deleteMutation = gql` mutation delete($id: ID!) { ${this.gqlNames.deleteMutationName}(id: $id) { @@ -61,6 +69,7 @@ export default class List { } } `; + this.deleteManyMutation = gql` mutation deleteMany($ids: [ID!]) { ${this.gqlNames.deleteManyMutationName}(ids: $ids) { @@ -143,10 +152,12 @@ export default class List { const count = Array.isArray(items) ? items.length : items; return count === 1 ? `1 ${this.singular}` : `${count} ${this.plural}`; } + getPersistedSearch() { - return localStorage.getItem(`search:${this.config.path}`); + return localStorage.getItem(`search:${this.path}`); } + setPersistedSearch(value) { - localStorage.setItem(`search:${this.config.path}`, value); + localStorage.setItem(`search:${this.path}`, value); } } diff --git a/packages/app-admin-ui/client/components/CreateItemModal.js b/packages/app-admin-ui/client/components/CreateItemModal.js index 754b9efdf26..29c9f6758cc 100644 --- a/packages/app-admin-ui/client/components/CreateItemModal.js +++ b/packages/app-admin-ui/client/components/CreateItemModal.js @@ -80,7 +80,7 @@ function CreateItemModal({ prefillData = {}, isLoading, createItem, onClose, onC // that we don't omit the required fields for client-side input validation. function hasnotChangedAndIsNotRequired(path) { const hasChanged = fieldsObject[path].hasChanged(initialValues, currentValues); - const isRequired = fieldsObject[path].config.isRequired; + const isRequired = fieldsObject[path].isRequired; return !hasChanged && !isRequired; } @@ -239,7 +239,5 @@ export default function CreateItemModalWithMutation(props) { errorPolicy: 'all', onError: error => handleCreateUpdateMutationError({ error, addToast }), }); - return ( - - ); + return ; } diff --git a/packages/app-admin-ui/client/components/ListTable.js b/packages/app-admin-ui/client/components/ListTable.js index 1dce95cf5e4..d1c970f2a45 100644 --- a/packages/app-admin-ui/client/components/ListTable.js +++ b/packages/app-admin-ui/client/components/ListTable.js @@ -310,7 +310,7 @@ export default function ListTable(props) { linkField = '_label_', } = props; - const [sortBy, onSortChange] = useListSort(list.key); + const [sortBy, onSortChange] = useListSort(); const handleSelectAll = () => { const allSelected = items && items.length === selectedItems.length; @@ -350,7 +350,7 @@ export default function ListTable(props) { - {authStrategy ? ( - - - Sign Out - - ) : null} - {ENABLE_DEV_FEATURES ? ( - - - - GitHub - - - - Graphiql Console - - - ) : null} - - ) : null; -} diff --git a/packages/app-admin-ui/client/components/Nav/ResizeHandler.js b/packages/app-admin-ui/client/components/Nav/ResizeHandler.js index 813fb2a49d2..08a60cca696 100644 --- a/packages/app-admin-ui/client/components/Nav/ResizeHandler.js +++ b/packages/app-admin-ui/client/components/Nav/ResizeHandler.js @@ -1,115 +1,128 @@ -import { Component } from 'react'; +import { useState, useRef, useEffect } from 'react'; import raf from 'raf-schd'; -import { withKeyboardConsumer } from '../KeyboardShortcuts'; +import { useKeyboardManager } from '../KeyboardShortcuts'; const LS_KEY = 'KEYSTONE_NAVIGATION_STATE'; const DEFAULT_STATE = { isCollapsed: false, width: 280 }; const MIN_WIDTH = 140; const MAX_WIDTH = 800; + export const KEYBOARD_SHORTCUT = '['; -function getCache() { - if (typeof localStorage !== 'undefined') { +const getCache = () => { + if (localStorage !== undefined) { const stored = localStorage.getItem(LS_KEY); - return stored ? JSON.parse(stored) : DEFAULT_STATE; + + if (stored) { + return JSON.parse(stored); + } } + return DEFAULT_STATE; -} -function setCache(state) { - if (typeof localStorage !== 'undefined') { +}; + +const setCache = state => { + if (localStorage !== undefined) { localStorage.setItem(LS_KEY, JSON.stringify(state)); } -} +}; -class ResizeHandler extends Component { - state = getCache(); +export const useResizeHandler = () => { + // TODO: should we be calling this in the function body? + const { width: cachedWidth, isCollapsed: cachedIsCollapsed } = getCache(); - componentDidMount() { - this.props.keyManager.subscribe(KEYBOARD_SHORTCUT, this.toggleCollapse); - } - componentWillUnmount() { - this.props.keyManager.unsubscribe(KEYBOARD_SHORTCUT); - } + // These should trigger renders + const [width, setWidth] = useState(cachedWidth); + const [isCollapsed, setIsCollapsed] = useState(cachedIsCollapsed); + const [isMouseDown, setIsMouseDown] = useState(false); + const [isDragging, setIsDragging] = useState(false); - storeState = s => { - // only keep the `isCollapsed` and `width` properties in locals storage - const isCollapsed = s.isCollapsed !== undefined ? s.isCollapsed : this.state.isCollapsed; - const width = s.width !== undefined ? s.width : this.state.width; + // Internal state tracking + const initialX = useRef(); + const initialWidth = useRef(); - setCache({ isCollapsed, width }); + const { addBinding, removeBinding } = useKeyboardManager(); - this.setState(s); - }; + useEffect(() => { + addBinding(KEYBOARD_SHORTCUT, toggleCollapse); + return () => { + removeBinding(KEYBOARD_SHORTCUT); + }; + }, []); + + useEffect(() => { + const handleResize = raf(event => { + // on occasion a mouse move event occurs before the event listeners have a chance to detach + if (!isMouseDown) return; + + // initialize dragging + if (!isDragging) { + setIsDragging(true); + initialWidth.current = width; + return; + } + + // allow the product nav to be 75% of the available page width + const adjustedMax = MAX_WIDTH - initialWidth.current; + const adjustedMin = MIN_WIDTH - initialWidth.current; + + const newDelta = Math.max(Math.min(event.pageX - initialX.current, adjustedMax), adjustedMin); + const newWidth = initialWidth.current + newDelta; + + setWidth(newWidth); + }); + + const handleResizeEnd = () => { + // reset non-width states + setIsDragging(false); + setIsMouseDown(false); + }; + + window.addEventListener('mousemove', handleResize, { passive: true }); + window.addEventListener('mouseup', handleResizeEnd, { passive: true }); + + return () => { + window.removeEventListener('mousemove', handleResize, { passive: true }); + window.removeEventListener('mouseup', handleResizeEnd, { passive: true }); + }; + }, [isMouseDown, isDragging]); + + // Only keep the `isCollapsed` and `width` properties in locals storage + useEffect(() => { + setCache({ isCollapsed, width }); + }, [isCollapsed, width]); - handleResizeStart = (event: MouseEvent) => { + const handleResizeStart = event => { // bail if not "left click" if (event.button && event.button > 0) return; // initialize resize gesture - this.setState({ initialX: event.pageX, mouseIsDown: true }); - - // attach handlers (handleResizeStart is a bound to onMouseDown) - window.addEventListener('mousemove', this.handleResize); - window.addEventListener('mouseup', this.handleResizeEnd); + initialX.current = event.pageX; + setIsMouseDown(true); }; - initializeDrag = () => { - let initialWidth = this.state.width; - - this.setState({ initialWidth, isDragging: true }); + const toggleCollapse = () => { + setIsCollapsed(prevCollapsed => !prevCollapsed); }; - handleResize = raf((event: MouseEvent) => { - const { initialX, initialWidth, isDragging, mouseIsDown } = this.state; - - // on occasion a mouse move event occurs before the event listeners - // have a chance to detach - if (!mouseIsDown) return; - - // initialize dragging - if (!isDragging) { - this.initializeDrag(event); - return; - } - - // allow the product nav to be 75% of the available page width - const adjustedMax = MAX_WIDTH - initialWidth; - const adjustedMin = MIN_WIDTH - initialWidth; - - const delta = Math.max(Math.min(event.pageX - initialX, adjustedMax), adjustedMin); - const width = initialWidth + delta; - - this.setState({ delta, width }); - }); - handleResizeEnd = () => { - // reset non-width states - this.setState({ delta: 0, isDragging: false, mouseIsDown: false }); - - // store the width - this.storeState({ width: this.state.width }); - - // cleanup - window.removeEventListener('mousemove', this.handleResize); - window.removeEventListener('mouseup', this.handleResizeEnd); - }; - toggleCollapse = () => { - const isCollapsed = !this.state.isCollapsed; - this.storeState({ isCollapsed }); + const resizeProps = { + title: 'Drag to Resize', + onMouseDown: handleResizeStart, }; - render() { - const resizeProps = { - title: 'Drag to Resize', - onMouseDown: this.handleResizeStart, - }; - const clickProps = { - onClick: this.toggleCollapse, - }; - const snapshot = this.state; + const clickProps = { + onClick: toggleCollapse, + }; - return this.props.children(resizeProps, clickProps, snapshot); - } -} + const snapshot = { + width, + isCollapsed, + isMouseDown, + isDragging, + initialX: initialX.current, + initialWidth: initialWidth.current, + }; -export default withKeyboardConsumer(ResizeHandler); + return { resizeProps, clickProps, snapshot }; +}; diff --git a/packages/app-admin-ui/client/components/Nav/index.js b/packages/app-admin-ui/client/components/Nav/index.js index bb5ccfe245a..67257429459 100644 --- a/packages/app-admin-ui/client/components/Nav/index.js +++ b/packages/app-admin-ui/client/components/Nav/index.js @@ -1,6 +1,7 @@ +/* global ENABLE_DEV_FEATURES */ /** @jsx jsx */ -import React, { useState } from 'react'; // eslint-disable-line no-unused-vars +import React, { useState, useMemo } from 'react'; // eslint-disable-line no-unused-vars import { Link, useRouteMatch } from 'react-router-dom'; import PropToggle from 'react-prop-toggle'; import { uid } from 'react-uid'; @@ -19,12 +20,11 @@ import { import { Title, Truncate } from '@arch-ui/typography'; import Tooltip from '@arch-ui/tooltip'; import { FlexGroup } from '@arch-ui/layout'; -import { PersonIcon } from '@arch-ui/icons'; +import { PersonIcon, SignOutIcon, TerminalIcon, MarkGithubIcon } from '@arch-ui/icons'; import { useAdminMeta } from '../../providers/AdminMeta'; -import ResizeHandler, { KEYBOARD_SHORTCUT } from './ResizeHandler'; -import { NavIcons } from './NavIcons'; -import ScrollQuery from '../ScrollQuery'; +import { useResizeHandler, KEYBOARD_SHORTCUT } from './ResizeHandler'; +import { useScrollQuery } from '../ScrollQuery'; import { useQuery } from '@apollo/react-hooks'; import gql from 'graphql-tag'; @@ -36,30 +36,36 @@ function camelToKebab(string) { return string.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); } -const Col = styled.div({ - alignItems: 'flex-start', - display: 'flex', - flex: 1, - flexDirection: 'column', - justifyContent: 'flex-start', - overflow: 'hidden', - width: '100%', -}); -const Inner = styled(Col)({ - height: ' 100vh', -}); -const Page = styled.div({ - flex: 1, - minHeight: '100vh', - position: 'relative', -}); -const PageWrapper = styled.div({ - display: 'flex', -}); -const Relative = styled(Col)({ - height: ' 100%', - position: 'relative', -}); +const Col = styled.div` + align-items: flex-start; + display: flex; + flex: 1; + flex-direction: column; + justify-content: flex-start; + overflow: hidden; + width: 100%; +`; + +const Inner = styled(Col)` + height: 100vh; + align-items: stretch; +`; + +const Page = styled.div` + flex: 1; + min-height: 100vh; + position: relative; +`; + +const PageWrapper = styled.div` + display: flex; +`; + +const Relative = styled(Col)` + height: 100%; + position: relative; +`; + const GrabHandle = styled.div(({ isActive }) => ({ backgroundColor: alpha(colors.text, 0.06), height: isActive ? '100%' : 0, @@ -88,6 +94,7 @@ const GrabHandle = styled.div(({ isActive }) => ({ top: -gridSize, }, })); + const CollapseExpand = styled.button(({ isCollapsed, mouseIsOverNav }) => { const size = 32; const offsetTop = 20; @@ -237,6 +244,7 @@ function PrimaryNavItems({ mouseIsOverNav, }) { const isAtDashboard = useRouteMatch({ path: adminPath, exact: true }); + const [scrollRef, snapshot] = useScrollQuery({ isPassive: false }); let hasRenderedIndexPage = false; const onRenderIndexPage = () => { @@ -271,31 +279,22 @@ function PrimaryNavItems({ ); return ( - - {(ref, snapshot) => ( - - {hasRenderedIndexPage === false && ( - - Dashboard - - )} - - {pageNavItems} - + + {hasRenderedIndexPage === false && ( + + Dashboard + )} - + + {pageNavItems} + ); } const UserInfoContainer = styled.div` - align-self: stretch; - padding: ${PRIMARY_NAV_GUTTER}px 0; - margin: 0 ${PRIMARY_NAV_GUTTER}px; + padding-bottom: ${PRIMARY_NAV_GUTTER}px; + margin: ${PRIMARY_NAV_GUTTER}px; border-bottom: 2px solid ${colors.N10}; display: flex; align-items: center; @@ -315,11 +314,17 @@ const UserIcon = styled.div` margin-right: ${PRIMARY_NAV_GUTTER}px; `; -const UserInfo = ({ authListKey, authListPath }) => { +const UserInfo = ({ authListPath }) => { + const { + authStrategy: { + gqlNames: { authenticatedQueryName }, + }, + } = useAdminMeta(); + // We're assuming the user list as a 'name' field const AUTHED_USER_QUERY = gql` query { - user: authenticated${authListKey} { + user: ${authenticatedQueryName} { id name } @@ -358,6 +363,65 @@ const UserInfo = ({ authListKey, authListPath }) => { ); }; +const GITHUB_PROJECT = 'https://github.com/keystonejs/keystone'; + +const ActionItems = ({ mouseIsOverNav }) => { + const { signoutPath, graphiqlPath, authStrategy } = useAdminMeta(); + + const entries = useMemo( + () => [ + ...(authStrategy + ? [ + { + label: 'Sign out', + to: signoutPath, + icon: SignOutIcon, + }, + ] + : []), + ...(ENABLE_DEV_FEATURES + ? [ + { + label: 'GraphQL Playground', + to: graphiqlPath, + icon: TerminalIcon, + target: '_blank', + }, + { + label: 'Keystone on GitHub', + to: GITHUB_PROJECT, + icon: MarkGithubIcon, + target: '_blank', + }, + ] + : []), + ], + [] // The admin meta never changes between server restarts + ); + + // No items to show + if (!entries.length) { + return null; + } + + return ( +
+ {entries.map(({ label, to, icon: ActionIcon, target }) => ( + + + {label} + + ))} +
+ ); +}; + const PrimaryNavContent = ({ mouseIsOverNav }) => { const { adminPath, @@ -375,15 +439,17 @@ const PrimaryNavContent = ({ mouseIsOverNav }) => { margin="both" crop css={{ + fontSize: '1.6em', color: colors.N90, textDecoration: 'none', - alignSelf: 'stretch', marginLeft: PRIMARY_NAV_GUTTER, marginRight: PRIMARY_NAV_GUTTER, }} > {name} + {authListKey && } + { pages={pages} mouseIsOverNav={mouseIsOverNav} /> - {authListKey && ( - - )} - ); }; @@ -414,81 +473,81 @@ const Nav = ({ children }) => { setMouseIsOverNav(false); }; - return ( - - {(resizeProps, clickProps, { isCollapsed, isDragging, width }) => { - const navWidth = isCollapsed ? 0 : width; - const makeResizeStyles = key => { - const pointers = isDragging ? { pointerEvents: 'none' } : null; - const transitions = isDragging - ? null - : { - transition: `${camelToKebab(key)} ${TRANSITION_DURATION} ${TRANSITION_EASING}`, - }; - return { [key]: navWidth, ...pointers, ...transitions }; + const { + resizeProps, + clickProps, + snapshot: { isCollapsed, isDragging, width }, + } = useResizeHandler(); + + const navWidth = isCollapsed ? 0 : width; + const makeResizeStyles = key => { + const pointers = isDragging ? { pointerEvents: 'none' } : null; + const transitions = isDragging + ? null + : { + transition: `${camelToKebab(key)} ${TRANSITION_DURATION} ${TRANSITION_EASING}`, }; + return { [key]: navWidth, ...pointers, ...transitions }; + }; - return ( - - - + + + + {isCollapsed ? null : ( + + )} + + {isCollapsed ? 'Click to Expand' : 'Click to Collapse'} + + } + placement="right" + hideOnMouseDown + hideOnKeyDown + delay={600} + > + {ref => ( + - - {isCollapsed ? null : ( - - )} - - {isCollapsed ? 'Click to Expand' : 'Click to Collapse'} - - } - placement="right" - hideOnMouseDown - hideOnKeyDown - delay={600} + - {ref => ( - - - - - - )} - - - {children} - - ); - }} - + + + + )} + + + {children} + ); }; diff --git a/packages/app-admin-ui/client/components/NoResults.js b/packages/app-admin-ui/client/components/NoResults.js index bc2082b0e62..14ed541da90 100644 --- a/packages/app-admin-ui/client/components/NoResults.js +++ b/packages/app-admin-ui/client/components/NoResults.js @@ -28,14 +28,12 @@ const NoResultsWrapper = ({ children, ...props }) => ( ); export const NoResults = ({ currentPage, filters, list, search }) => { - const { onChange } = useListPagination(list.key); + const { onChange } = useListPagination(); const onResetPage = () => onChange(1); const pageDepthMessage = ( -

- Not enough {list.plural.toLowerCase()} found to show page {currentPage}. -

+

{`Not enough ${list.plural.toLowerCase()} found to show page ${currentPage}.`}

@@ -49,8 +47,9 @@ export const NoResults = ({ currentPage, filters, list, search }) => { if (filters && filters.length) { return ( - No {list.plural.toLowerCase()} found matching the{' '} - {filters.length > 1 ? 'filters' : 'filter'} + {`No ${list.plural.toLowerCase()} found matching the ${ + filters.length > 1 ? 'filters' : 'filter' + }`} ); } @@ -58,12 +57,10 @@ export const NoResults = ({ currentPage, filters, list, search }) => { if (search && search.length) { return ( - No {list.plural.toLowerCase()} found matching “ - {search} - ” + {`No ${list.plural.toLowerCase()} found matching “${search}”`} ); } - return No {list.plural.toLowerCase()} to display yet...; + return {`No ${list.plural.toLowerCase()} to display yet...`}; }; diff --git a/packages/app-admin-ui/client/components/ScrollQuery.js b/packages/app-admin-ui/client/components/ScrollQuery.js index 2f55e8f9a9c..c280755c114 100644 --- a/packages/app-admin-ui/client/components/ScrollQuery.js +++ b/packages/app-admin-ui/client/components/ScrollQuery.js @@ -1,76 +1,69 @@ -import { createRef, Component } from 'react'; -import PropTypes from 'prop-types'; +import { useState, useEffect, useRef, useCallback } from 'react'; import ResizeObserver from 'resize-observer-polyfill'; import raf from 'raf-schd'; const LISTENER_OPTIONS = { passive: true }; -export default class ScrollQuery extends Component { - scrollElement = createRef(); - state = { hasScroll: false, isScrollable: false, scrollTop: 0 }; - static propTypes = { - children: PropTypes.func, - isPassive: PropTypes.bool, - }; - static defaultProps = { - isPassive: true, - }; +export const useScrollQuery = ({ isPassive = true }) => { + const scrollElement = useRef(); + const resizeObserver = useRef(); - componentDidMount() { - const { isPassive } = this.props; - const scrollEl = this.scrollElement.current; + const [snapshot, setSnapshot] = useState({}); - if (!isPassive) { - scrollEl.addEventListener('scroll', this.handleScroll, LISTENER_OPTIONS); - } + const setScroll = useCallback(target => { + const { clientHeight, scrollHeight, scrollTop } = target; + + const isBottom = scrollTop === scrollHeight - clientHeight; + const isTop = scrollTop === 0; + const isScrollable = scrollHeight > clientHeight; + const hasScroll = !!scrollTop; - this.resizeObserver = new ResizeObserver(([entry]) => { - this.setScroll(entry.target); + setSnapshot({ + isBottom, + isTop, + isScrollable, + scrollHeight, + scrollTop, + hasScroll, }); - this.resizeObserver.observe(scrollEl); + }, []); - this.setScroll(scrollEl); - } - componentWillUnmount() { - const { isPassive } = this.props; + useEffect(() => { + const { current } = scrollElement; - if (!isPassive) { - this.scrollElement.current.removeEventListener('scroll', this.handleScroll, LISTENER_OPTIONS); - } + if (!isPassive && current) { + const handleScroll = raf(event => { + setScroll(event.target); + }); - if (this.resizeObserver && this.scrollElement.current) { - this.resizeObserver.disconnect(this.scrollElement.current); + current.addEventListener('scroll', handleScroll, LISTENER_OPTIONS); + return () => { + current.removeEventListener('scroll', handleScroll, LISTENER_OPTIONS); + }; } - this.resizeObserver = null; - } + }, [isPassive]); - handleScroll = raf(event => { - this.setScroll(event.target); - }); + // Not using useResizeObserver since we want to operate with the element on resize, not the dimensions + useEffect(() => { + const { current } = scrollElement; - setScroll = target => { - const { clientHeight, scrollHeight, scrollTop } = target; - const isScrollable = scrollHeight > clientHeight; - const isBottom = scrollTop === scrollHeight - clientHeight; - const isTop = scrollTop === 0; - const hasScroll = !!scrollTop; - if ( - // we only need to compare some parts of state - // because some of the parts are computed from scrollTop - this.state.isBottom !== isBottom || - this.state.isScrollable !== isScrollable || - this.state.scrollHeight !== scrollHeight || - this.state.scrollTop !== scrollTop - ) { - this.setState({ isBottom, isTop, isScrollable, scrollHeight, scrollTop, hasScroll }); - } - }; + resizeObserver.current = new ResizeObserver( + raf(([entry]) => { + setScroll(entry.target); + }) + ); + + resizeObserver.current.observe(current); + setScroll(current); + + return () => { + if (resizeObserver.current && current) { + resizeObserver.current.disconnect(current); + } - render() { - const { children, render } = this.props; - const ref = this.scrollElement; - const snapshot = this.state; + resizeObserver.current = null; + }; + }, []); - return render ? render(ref, snapshot) : children(ref, snapshot); - } -} + return [scrollElement, snapshot]; +}; diff --git a/packages/app-admin-ui/client/components/UpdateManyItemsModal.js b/packages/app-admin-ui/client/components/UpdateManyItemsModal.js index fb0b3414876..590e7638759 100644 --- a/packages/app-admin-ui/client/components/UpdateManyItemsModal.js +++ b/packages/app-admin-ui/client/components/UpdateManyItemsModal.js @@ -1,35 +1,35 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; -import { Component, Fragment, useMemo, useCallback, Suspense } from 'react'; +import { Fragment, useMemo, useCallback, Suspense, useState } from 'react'; import { useMutation } from '@apollo/react-hooks'; +import { useToasts } from 'react-toast-notifications'; +import { omit, arrayToObject, countArrays } from '@keystonejs/utils'; + import { Button, LoadingButton } from '@arch-ui/button'; import Drawer from '@arch-ui/drawer'; import { FieldContainer, FieldLabel, FieldInput } from '@arch-ui/fields'; -import Select from '@arch-ui/select'; -import { omit, arrayToObject, countArrays } from '@keystonejs/utils'; import { LoadingIndicator } from '@arch-ui/loading'; +import Select from '@arch-ui/select'; -import { validateFields } from '../util'; +import { validateFields, handleCreateUpdateMutationError } from '../util'; import CreateItemModal from './CreateItemModal'; -let Render = ({ children }) => children(); - -class UpdateManyModal extends Component { - constructor(props) { - super(props); - const { list } = props; - const selectedFields = []; - const item = list.getInitialItemData(); - const validationErrors = {}; - const validationWarnings = {}; - - this.state = { item, selectedFields, validationErrors, validationWarnings }; - } - onUpdate = async () => { - const { updateItem, isLoading, items } = this.props; - const { item, selectedFields, validationErrors, validationWarnings } = this.state; - if (isLoading) return; - if (countArrays(validationErrors)) { +const Render = ({ children }) => children(); + +const UpdateManyModal = ({ list, items, isOpen, onUpdate, onClose }) => { + const { addToast } = useToasts(); + const [updateItem, { loading }] = useMutation(list.updateManyMutation, { + errorPolicy: 'all', + onError: error => handleCreateUpdateMutationError({ error, addToast }), + }); + + const [item, setItem] = useState({}); + const [selectedFields, setSelectedFields] = useState([]); + const [validationErrors, setValidationErrors] = useState({}); + const [validationWarnings, setValidationWarnings] = useState({}); + + const handleUpdate = async () => { + if (loading || countArrays(validationErrors)) { return; } @@ -39,171 +39,163 @@ class UpdateManyModal extends Component { const { errors, warnings } = await validateFields(selectedFields, item, data); if (countArrays(errors) + countArrays(warnings) > 0) { - this.setState(() => ({ - validationErrors: errors, - validationWarnings: warnings, - })); + setValidationErrors(errors); + setValidationWarnings(warnings); return; } } - updateItem({ + const result = await updateItem({ variables: { data: items.map(id => ({ id, data })), }, - }).then(() => { - this.props.onUpdate(); - this.resetState(); }); + + // Result will be undefined if a GraphQL error occurs (such as failed validation) + // Leave the modal open in that case + if (!result) return; + + resetState(); + onUpdate(); }; - resetState = () => { - this.setState({ item: this.props.list.getInitialItemData({}), selectedFields: [] }); + const resetState = () => { + setItem(list.getInitialItemData({})); + setSelectedFields([]); }; - onClose = () => { - const { isLoading } = this.props; - if (isLoading) return; - this.resetState(); - this.props.onClose(); + + const handleClose = () => { + if (loading) return; + resetState(); + onClose(); }; - onKeyDown = event => { + + const onKeyDown = event => { if (event.defaultPrevented) return; switch (event.key) { case 'Escape': - return this.onClose(); + return handleClose(); case 'Enter': - return this.onUpdate(); + return handleUpdate(); } }; - handleSelect = selected => { - const { list } = this.props; - const selectedFields = selected.map(({ path, value }) => { - return list.fields - .filter(({ isPrimaryKey }) => !isPrimaryKey) - .find(f => f.path === path || f.path === value); - }); - this.setState({ selectedFields }); - }; - getOptionValue = option => { - return option.path || option.value; + + const handleSelect = selected => { + setSelectedFields( + selected + ? selected.map(({ path, value }) => { + return list.fields + .filter(({ isPrimaryKey }) => !isPrimaryKey) + .find(f => f.path === path || f.path === value); + }) + : [] + ); }; - getOptionValue = option => { + + const getOptionValue = option => { return option.path || option.value; }; - getOptions = () => { - const { list } = this.props; + + const options = useMemo( // remove the `options` key from select type fields - return list.fields.filter(({ isPrimaryKey }) => !isPrimaryKey).map(f => omit(f, ['options'])); - }; - render() { - const { isLoading, isOpen, items, list } = this.props; - const { item, selectedFields, validationErrors, validationWarnings } = this.state; - const options = this.getOptions(); - - const hasWarnings = countArrays(validationWarnings); - const hasErrors = countArrays(validationErrors); - - return ( - - - {hasWarnings && !hasErrors ? 'Ignore Warnings and Update' : 'Update'} - - - - } - > - - - - + + + {selectedFields.map((field, i) => { + return ( + } + key={field.path} + > + + {() => { + const [Field] = field.adminMeta.readViews([field.views.Field]); + // eslint-disable-next-line react-hooks/rules-of-hooks + const onChange = useCallback( + value => { + setItem(prev => ({ ...prev, [field.path]: value })); + setValidationErrors({}); + setValidationWarnings({}); + }, + [field] + ); + // eslint-disable-next-line react-hooks/rules-of-hooks + return useMemo( + () => ( + + ), + [ + i, + field, + item[field.path], + validationErrors[field.path], + validationWarnings[field.path], + onChange, + ] + ); + }} + + + ); + })} + + ); +}; - return ; -} +export default UpdateManyModal; diff --git a/packages/app-admin-ui/client/index.js b/packages/app-admin-ui/client/index.js index 0b232fe4d85..13094315f07 100644 --- a/packages/app-admin-ui/client/index.js +++ b/packages/app-admin-ui/client/index.js @@ -72,15 +72,9 @@ export const KeystoneAdminUI = () => { } return ( - + - ( - - )} - /> + } /> , { match: { params: { itemId }, }, - }) => } + }) => } /> , } />, diff --git a/packages/app-admin-ui/client/pages/Item/ItemTitle.js b/packages/app-admin-ui/client/pages/Item/ItemTitle.js index b38bcf2b53d..002e0e61a49 100644 --- a/packages/app-admin-ui/client/pages/Item/ItemTitle.js +++ b/packages/app-admin-ui/client/pages/Item/ItemTitle.js @@ -37,7 +37,7 @@ export const ItemTitle = memo(function ItemTitle({ titleText, adminPath }) { to={listHref} css={{ marginLeft: -12 }} > - Back + {list.label}
diff --git a/packages/app-admin-ui/client/pages/Item/Search.js b/packages/app-admin-ui/client/pages/Item/Search.js index 153f9c289e8..c1837b6b7d3 100644 --- a/packages/app-admin-ui/client/pages/Item/Search.js +++ b/packages/app-admin-ui/client/pages/Item/Search.js @@ -14,7 +14,6 @@ import Tooltip from '@arch-ui/tooltip'; import { useAdminMeta } from '../../providers/AdminMeta'; export function Search({ list }) { - // const { urlState } = useListUrlState(list.key); const [value, setValue] = useState(''); const [formIsVisible, setFormVisible] = useState(false); const inputRef = useRef(null); diff --git a/packages/app-admin-ui/client/pages/Item/index.js b/packages/app-admin-ui/client/pages/Item/index.js index c7765eae601..3050a8e41a2 100644 --- a/packages/app-admin-ui/client/pages/Item/index.js +++ b/packages/app-admin-ui/client/pages/Item/index.js @@ -37,6 +37,7 @@ import { import { ItemTitle } from './ItemTitle'; import { ItemProvider } from '../../providers/Item'; import { useAdminMeta } from '../../providers/AdminMeta'; +import { useList } from '../../providers/List'; let Render = ({ children }) => children(); @@ -82,6 +83,8 @@ const ItemDetails = ({ const history = useHistory(); const { addToast } = useToasts(); + const { query: listQuery } = useList(); + const getFieldsObject = memoizeOne(() => arrayToObject( // NOTE: We _exclude_ read only fields @@ -116,20 +119,25 @@ const ItemDetails = ({ } }; - const onDelete = deletePromise => { + const onDelete = async deletePromise => { deleteConfirmed.current = true; - deletePromise - .then(() => { - if (mounted) { - setShowDeleteModal(false); - } - history.replace(`${adminPath}/${list.path}`); - toastItemSuccess({ addToast }, initialData, 'Deleted successfully'); - }) - .catch(error => { - toastError({ addToast }, error); - }); + try { + await deletePromise; + const refetch = listQuery.refetch(); + + if (mounted) { + setShowDeleteModal(false); + } + + toastItemSuccess({ addToast }, initialData, 'Deleted successfully'); + + // Wait for the refetch to finish before returning to the list + await refetch; + history.replace(`${adminPath}/${list.path}`); + } catch (error) { + toastError({ addToast }, error); + } }; const openDeleteModal = () => { @@ -207,44 +215,34 @@ const ItemDetails = ({ // Cache the current item data at the time of saving. itemSaveCheckCache.current = item; - updateItem({ variables: { id: item.id, data } }) - .then(() => { - const toastContent = ( -
- {item._label_ ? {item._label_} : null} -
Saved successfully
-
- ); + await updateItem({ variables: { id: item.id, data } }); - addToast(toastContent, { - autoDismiss: true, - appearance: 'success', - }); + setValidationErrors({}); + setValidationWarnings({}); - setValidationErrors({}); - setValidationWarnings({}); + // we only want to set itemHasChanged to false + // when it hasn't changed since we did the mutation + // otherwise a user could edit the data and + // accidentally close the page without a warning + if (item === itemSaveCheckCache.current) { + itemHasChanged.current = false; + } - // we only want to set itemHasChanged to false - // when it hasn't changed since we did the mutation - // otherwise a user could edit the data and - // accidentally close the page without a warning - if (item === itemSaveCheckCache.current) { - itemHasChanged.current = false; - } - }) - .then(onUpdate) - .then(savedItem => { - // No changes since we kicked off the item saving - if (!itemHasChanged.current) { - // Then reset the state to the current server value - // This ensures we are able to pass any extra information returned - // from the server that otherwise would be unknown to client state - setItem(savedItem); - - // Clear the cache - itemSaveCheckCache.current = {}; - } - }); + const savedItem = await onUpdate(); + + // Defer the toast to this point since it ensures up-to-date data, such as for _label_. + toastItemSuccess({ addToast }, savedItem, 'Saved successfully'); + + // No changes since we kicked off the item saving + if (!itemHasChanged.current) { + // Then reset the state to the current server value + // This ensures we are able to pass any extra information returned + // from the server that otherwise would be unknown to client state + setItem(savedItem); + + // Clear the cache + itemSaveCheckCache.current = {}; + } }; const onCreate = ({ data }) => { @@ -359,8 +357,9 @@ const ItemNotFound = ({ adminPath, errorMessage, list }) => ( ); -const ItemPage = ({ list, itemId }) => { - const { adminPath, getListByKey } = useAdminMeta(); +const ItemPage = ({ itemId }) => { + const { list } = useList(); + const { adminPath } = useAdminMeta(); const { addToast } = useToasts(); const itemQuery = list.getItemQuery(itemId); @@ -454,7 +453,6 @@ const ItemPage = ({ list, itemId }) => { itemErrors={itemErrors} key={itemId} list={list} - getListByKey={getListByKey} onUpdate={() => refetch().then(refetchedData => deserializeItem(list, refetchedData.data[list.gqlNames.itemQueryName]) diff --git a/packages/app-admin-ui/client/pages/List/ColumnSelect.js b/packages/app-admin-ui/client/pages/List/ColumnSelect.js index 67395b314de..05681352b30 100644 --- a/packages/app-admin-ui/client/pages/List/ColumnSelect.js +++ b/packages/app-admin-ui/client/pages/List/ColumnSelect.js @@ -4,16 +4,13 @@ import { jsx } from '@emotion/core'; import { OptionPrimitive, CheckMark } from '@arch-ui/options'; import { Popout, POPOUT_GUTTER } from '../../components/Popout'; -import { useList, useListColumns } from './dataHooks'; +import { useListColumns } from './dataHooks'; +import { useList } from '../../providers/List'; import FieldSelect from './FieldSelect'; -type Props = { - listKey: string, -}; - -export default function ColumnPopout({ listKey, target }: Props) { - const list = useList(listKey); - const [columns, handleColumnChange] = useListColumns(listKey); +export default function ColumnPopout({ target }) { + const { list } = useList(); + const [columns, handleColumnChange] = useListColumns(); const cypresSelectId = 'ks-column-select'; return ( diff --git a/packages/app-admin-ui/client/pages/List/Filters/ActiveFilters.js b/packages/app-admin-ui/client/pages/List/Filters/ActiveFilters.js index 28903480d7c..20979a7c003 100644 --- a/packages/app-admin-ui/client/pages/List/Filters/ActiveFilters.js +++ b/packages/app-admin-ui/client/pages/List/Filters/ActiveFilters.js @@ -17,7 +17,7 @@ export const elementOffsetStyles = { }; export default function ActiveFilters({ list }) { - const { filters, onAdd, onRemove, onRemoveAll, onUpdate } = useListFilter(list.key); + const { filters, onAdd, onRemove, onRemoveAll, onUpdate } = useListFilter(); return ( diff --git a/packages/app-admin-ui/client/pages/List/Filters/AddFilterPopout.js b/packages/app-admin-ui/client/pages/List/Filters/AddFilterPopout.js index 179e7772ff4..42b8b48120c 100644 --- a/packages/app-admin-ui/client/pages/List/Filters/AddFilterPopout.js +++ b/packages/app-admin-ui/client/pages/List/Filters/AddFilterPopout.js @@ -181,7 +181,7 @@ export default class AddFilterPopout extends Component { const { field, filter, value } = this.state; event.preventDefault(); - if (!filter || value === null) return; + if (!filter || value === null || field.getFilterValue(value) === null) return; onChange({ field, label: filter.label, type: filter.type, value }); }; diff --git a/packages/app-admin-ui/client/pages/List/Filters/EditFilterPopout.js b/packages/app-admin-ui/client/pages/List/Filters/EditFilterPopout.js index 51ea82b6bb2..12c294b5c5a 100644 --- a/packages/app-admin-ui/client/pages/List/Filters/EditFilterPopout.js +++ b/packages/app-admin-ui/client/pages/List/Filters/EditFilterPopout.js @@ -12,7 +12,7 @@ export default class EditFilterPopout extends Component { onSubmit = () => { const { filter, onChange } = this.props; const { value } = this.state; - if (value === null) return; + if (value === null || filter.field.getFilterValue(value) === null) return; onChange({ field: filter.field, label: filter.label, diff --git a/packages/app-admin-ui/client/pages/List/Management.js b/packages/app-admin-ui/client/pages/List/Management.js index d3a6806a0e2..9c20d109fef 100644 --- a/packages/app-admin-ui/client/pages/List/Management.js +++ b/packages/app-admin-ui/client/pages/List/Management.js @@ -17,74 +17,75 @@ export const ManageToolbar = styled.div(({ isVisible }) => ({ marginTop: gridSize, visibility: isVisible ? 'visible' : 'hidden', })); -const SelectedCount = styled.div({ - color: colors.N40, - marginRight: gridSize, -}); -export default function ListManage(props) { - const { onDeleteMany, onUpdateMany, selectedItems } = props; - const [deleteModalIsVisible, setDeleteModal] = useState(false); - const [updateModalIsVisible, setUpdateModal] = useState(false); +const SelectedCount = styled.div` + color: ${colors.N40}; + margin-right: ${gridSize}px; +`; + +const ListManage = ({ list, pageSize, totalItems, selectedItems, onDeleteMany, onUpdateMany }) => { + const [deleteModalIsVisible, setDeleteModalIsVisible] = useState(false); + const [updateModalIsVisible, setUpdateModalIsVisible] = useState(false); const handleDelete = () => { - setDeleteModal(false); + setDeleteModalIsVisible(false); onDeleteMany(); }; + const handleUpdate = () => { - setUpdateModal(false); + setUpdateModalIsVisible(false); onUpdateMany(); }; - const { list, pageSize, totalItems } = props; - const selectedCount = selectedItems.length; - return ( - {selectedCount} of {Math.min(pageSize, totalItems)} Selected + {`${selectedItems.length} of ${Math.min(pageSize, totalItems)} Selected`} - {ENABLE_DEV_FEATURES ? ( - list.access.update ? ( - setUpdateModal(true)} - variant="nuance" - data-test-name="update" - > - Update - - ) : null - ) : null} - {list.access.update ? ( + + {ENABLE_DEV_FEATURES && list.access.update && ( + setUpdateModalIsVisible(true)} + variant="nuance" + data-test-name="update" + > + Update + + )} + + {list.access.update && ( setDeleteModal(true)} + onClick={() => setDeleteModalIsVisible(true)} variant="nuance" data-test-name="delete" > Delete - ) : null} + )} setUpdateModal(false)} + onClose={() => setUpdateModalIsVisible(false)} onUpdate={handleUpdate} /> + setDeleteModal(false)} + onClose={() => setDeleteModalIsVisible(false)} onDelete={handleDelete} /> ); -} +}; + +export default ListManage; diff --git a/packages/app-admin-ui/client/pages/List/MoreDropdown.js b/packages/app-admin-ui/client/pages/List/MoreDropdown.js index f38d9e4c831..64ee55ee13d 100644 --- a/packages/app-admin-ui/client/pages/List/MoreDropdown.js +++ b/packages/app-admin-ui/client/pages/List/MoreDropdown.js @@ -9,14 +9,14 @@ import { useMeasure } from '@arch-ui/hooks'; import { useReset } from './dataHooks'; -let dropdownTarget = props => ( +const dropdownTarget = props => ( Show more... ); export function MoreDropdown({ listKey, measureRef, isFullWidth, onFullWidthToggle }) { - let { width } = useMeasure(measureRef); + const { width } = useMeasure(measureRef); const onReset = useReset(listKey); const TableIcon = isFullWidth ? FoldIcon : UnfoldIcon; const tableToggleIsAvailable = width > CONTAINER_WIDTH + CONTAINER_GUTTER * 2; diff --git a/packages/app-admin-ui/client/pages/List/Pagination.js b/packages/app-admin-ui/client/pages/List/Pagination.js index e23e9b76ba2..675d5eaf921 100644 --- a/packages/app-admin-ui/client/pages/List/Pagination.js +++ b/packages/app-admin-ui/client/pages/List/Pagination.js @@ -6,8 +6,8 @@ import { useListPagination } from './dataHooks'; const CYPRESS_TEST_ID = 'ks-pagination'; -export default function ListPagination({ isLoading, listKey }) { - const { data, onChange } = useListPagination(listKey); +export default function ListPagination({ isLoading }) { + const { data, onChange } = useListPagination(); return ( pageSize) { count = `Showing ${start} to ${end} of ${total}`; } else { - count = 'Showing ' + total; + count = `Showing ${total} `; if (total > 1 && plural) { - count += ' ' + plural; + count += plural; } else if (total === 1 && singular) { - count += ' ' + singular; + count += singular; } } diff --git a/packages/app-admin-ui/client/pages/List/Search.js b/packages/app-admin-ui/client/pages/List/Search.js index ebd6c20747d..2703dadc261 100644 --- a/packages/app-admin-ui/client/pages/List/Search.js +++ b/packages/app-admin-ui/client/pages/List/Search.js @@ -15,7 +15,7 @@ import { useListSearch } from './dataHooks'; import { elementOffsetStyles } from './Filters/ActiveFilters'; export default function Search({ isLoading, list }) { - const { searchValue, onChange, onClear, onSubmit } = useListSearch(list.key); + const { searchValue, onChange, onClear, onSubmit } = useListSearch(); const [value, setValue] = useState(searchValue); const inputRef = useRef(); diff --git a/packages/app-admin-ui/client/pages/List/SortSelect.js b/packages/app-admin-ui/client/pages/List/SortSelect.js index 50b68cd6a69..3b8eb118346 100644 --- a/packages/app-admin-ui/client/pages/List/SortSelect.js +++ b/packages/app-admin-ui/client/pages/List/SortSelect.js @@ -9,15 +9,12 @@ import { Kbd } from '@arch-ui/typography'; import { Button } from '@arch-ui/button'; import { DisclosureArrow, Popout, POPOUT_GUTTER } from '../../components/Popout'; -import { useList, useListSort, useKeyDown } from './dataHooks'; +import { useListSort, useKeyDown } from './dataHooks'; +import { useList } from '../../providers/List'; -type Props = { - listKey: string, -}; - -export default function SortPopout({ listKey }: Props) { - const list = useList(listKey); - const [sortValue, handleSortChange] = useListSort(listKey); +export default function SortPopout() { + const { list } = useList(); + const [sortValue, handleSortChange] = useListSort(); const altIsDown = useKeyDown('Alt'); const popoutRef = useRef(); diff --git a/packages/app-admin-ui/client/pages/List/dataHooks.js b/packages/app-admin-ui/client/pages/List/dataHooks.js index 0e74085ad8f..6ddea3b08e3 100644 --- a/packages/app-admin-ui/client/pages/List/dataHooks.js +++ b/packages/app-admin-ui/client/pages/List/dataHooks.js @@ -1,34 +1,14 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useMemo } from 'react'; import { useHistory, useLocation, useRouteMatch } from 'react-router-dom'; -import { useQuery } from '@apollo/react-hooks'; -import { deconstructErrorsToDataShape } from '../../util'; import { pseudoLabelField } from './FieldSelect'; import { decodeSearch, encodeSearch } from './url-state'; -import { useAdminMeta } from '../../providers/AdminMeta'; - -/** - * List Hook - * ------------------------------ - * @param {string} listKey - The key for the list to operate on. - * @returns {Object} list - The matching list object - */ - -export const useList = listKey => { - const { getListByKey } = useAdminMeta(); - const list = getListByKey(listKey); - - if (!list) { - throw new Error(`No list matching key "${listKey}"`); - } - - return list; -}; +import { useList } from '../../providers/List'; /** * URL State Hook * ------------------------------ - * @param {string} listKey - The key for the list to operate on. + * @param {Object} list - The the list to operate on. * @returns {Object} * - decodeConfig - config necessary to decode url state * - urlState - the current list state decoded from from the URL @@ -49,57 +29,26 @@ export const useList = listKey => { // match: MatchInterface, // }; -export function useListUrlState(listKey) { - const history = useHistory(); +export function useListUrlState(list) { const location = useLocation(); - const match = useRouteMatch(); - const list = useList(listKey); - const decodeConfig = { history, location, match, list }; - const urlState = decodeSearch(location.search, decodeConfig); - return { decodeConfig, urlState }; -} + const decodeConfig = { list }; + const urlState = useMemo(() => decodeSearch(location.search, decodeConfig), [location.search]); -/** - * Query Hook - * ------------------------------ - * @param {string} listKey - The key for the list to operate on. - * @returns {Object} - * - data - maybe some data - * - error - maybe an error - * - loading - whether the query is loading - * - refetch - function to refetch data - */ - -export function useListQuery(listKey) { - const list = useList(listKey); - const { urlState } = useListUrlState(listKey); - - // Query prep - const { currentPage, fields, filters, pageSize, search, sortBy } = urlState; - const orderBy = sortBy ? `${sortBy.field.path}_${sortBy.direction}` : null; - const first = pageSize; - const skip = (currentPage - 1) * pageSize; - - // Get and store items - const query = list.getQuery({ fields, filters, search, orderBy, skip, first }); - const result = useQuery(query, { fetchPolicy: 'cache-and-network' }); - - return result; + return { decodeConfig, urlState }; } /** * Modifier Hook * ------------------------------ - * @param {string} listKey - The key for the list to operate on. * @returns {function} setSearch - Used for internal hooks to modify the URL */ -export function useListModifier(listKey) { +export function useListModifier() { const history = useHistory(); const location = useLocation(); - const list = useList(listKey); - const { decodeConfig, urlState } = useListUrlState(listKey); + const { list } = useList(); + const { urlState, decodeConfig } = useListUrlState(list); /** * setSearch @@ -132,47 +81,16 @@ export function useListModifier(listKey) { }; } -/** - * Items Hook - * ------------------------------ - * @param {string} listKey - The key for the list to operate on. - * @returns {Object} - * - items - array, length `pageSize`, of item data (fields matching the current columns) - * - itemCount - the number of all items; regardles of filters, page etc. - * - itemErrors - array of errors - */ - -export function useListItems(listKey) { - const [items, setItems] = useState([]); - const [itemErrors, setErrors] = useState([]); - const [itemCount, setCount] = useState(0); - - const list = useList(listKey); - const { data = {} } = useListQuery(listKey); - - useEffect(() => { - if (data[list.gqlNames.listQueryName]) { - setItems(data[list.gqlNames.listQueryName].map(item => list.deserializeItemData(item))); - setErrors(deconstructErrorsToDataShape(data.error)[list.gqlNames.listQueryName]); - } - if (data[list.gqlNames.listQueryMetaName]) { - setCount(data[list.gqlNames.listQueryMetaName].count); - } - }, [data, list]); - - return { items, itemCount, itemErrors }; -} - /** * Reset Hook * ------------------------------ - * @param {string} listKey - The key for the list to operate on. * @returns {Function} - The function to reset url state */ -export function useReset(listKey) { - const { decodeConfig } = useListUrlState(listKey); - const setSearch = useListModifier(listKey); +export function useReset() { + const { list } = useList(); + const { decodeConfig } = useListUrlState(list); + const setSearch = useListModifier(); return () => setSearch(decodeSearch('', decodeConfig)); } @@ -180,7 +98,6 @@ export function useReset(listKey) { /** * Search Hook * ------------------------------ - * @param {string} listKey - The key for the list to operate on. * @returns {Object} * - searchValue - the current search string * - onChange - change the current search @@ -188,11 +105,16 @@ export function useReset(listKey) { * - onSubmit - commit the current search to history and update the URL */ -export function useListSearch(listKey) { - const { urlState } = useListUrlState(listKey); - const { search: searchValue } = urlState; - const setSearch = useListModifier(listKey); - const { items } = useListItems(listKey); +export function useListSearch() { + const { + list, + listData: { items }, + } = useList(); + const { + urlState: { search: searchValue }, + } = useListUrlState(list); + const setSearch = useListModifier(); + const history = useHistory(); const match = useRouteMatch(); @@ -231,7 +153,6 @@ export function useListSearch(listKey) { /** * Filter Hook * ------------------------------ - * @param {string} listKey - The key for the list to operate on. * @returns {Object} * - filters - the active filter array * - onRemove - remove a given filter @@ -248,10 +169,12 @@ export function useListSearch(listKey) { // value: any, // }> -export function useListFilter(listKey) { - const { urlState } = useListUrlState(listKey); - const { filters } = urlState; - const setSearch = useListModifier(listKey); +export function useListFilter() { + const { list } = useList(); + const { + urlState: { filters }, + } = useListUrlState(list); + const setSearch = useListModifier(); const onRemove = value => () => { const newFilters = filters.filter(f => { @@ -259,13 +182,16 @@ export function useListFilter(listKey) { }); setSearch({ filters: newFilters }); }; + const onRemoveAll = () => { setSearch({ filters: [] }); }; + const onAdd = value => { filters.push(value); setSearch({ filters }); }; + const onUpdate = updatedFilter => { const updateIndex = filters.findIndex(i => { return i.field.path === updatedFilter.field.path && i.type === updatedFilter.type; @@ -281,7 +207,6 @@ export function useListFilter(listKey) { /** * Pagination Hook * ------------------------------ - * @param {string} listKey - The key for the list to operate on. * @returns {Object} * - data - the pagination data * - onChange - change the current page @@ -296,15 +221,20 @@ export function useListFilter(listKey) { // pageSize: numnber, // } -export function useListPagination(listKey) { - const { urlState } = useListUrlState(listKey); - const { currentPage, pageSize } = urlState; - const setSearch = useListModifier(listKey); - const { itemCount } = useListItems(listKey); +export function useListPagination() { + const { + list, + listData: { itemCount }, + } = useList(); + const { + urlState: { currentPage, pageSize }, + } = useListUrlState(list); + const setSearch = useListModifier(); const onChange = cp => { setSearch({ currentPage: cp }); }; + const onChangeSize = ps => { setSearch({ pageSize: ps }); }; @@ -323,7 +253,6 @@ export function useListPagination(listKey) { /** * Sort Hook * ------------------------------ - * @param {string} listKey - The key for the list to operate on. * @returns {[Object, Function]} * - sortBy - the sort config object * - onChange - the change handler for sorting @@ -334,10 +263,12 @@ export function useListPagination(listKey) { // field: { label: string, path: string }, // }; -export function useListSort(listKey) { - const { urlState } = useListUrlState(listKey); - const { sortBy } = urlState; - const setSearch = useListModifier(listKey); +export function useListSort() { + const { list } = useList(); + const { + urlState: { sortBy }, + } = useListUrlState(list); + const setSearch = useListModifier(); const onChange = sb => { setSearch({ sortBy: sb }); @@ -349,7 +280,6 @@ export function useListSort(listKey) { /** * Column Hook * ------------------------------ - * @param {string} listKey - The key for the list to operate on. * @returns {[Object, Function]} * - fields - an array of the current columns * - onChange - the change handler for columns @@ -357,11 +287,12 @@ export function useListSort(listKey) { // type Fields = Array; -export function useListColumns(listKey) { - const list = useList(listKey); - const { urlState } = useListUrlState(listKey); - const { fields } = urlState; - const setSearch = useListModifier(listKey); +export function useListColumns() { + const { list } = useList(); + const { + urlState: { fields }, + } = useListUrlState(list); + const setSearch = useListModifier(); const onChange = selectedFields => { // Ensure that the displayed fields maintain their original sortDirection @@ -405,7 +336,7 @@ export function useListSelect(items) { }; const shiftIsDown = useKeyDown('Shift', [handleKeyDown, handleKeyUp]); - const onSelect = (value: string | Array) => { + const onSelect = value => { let nextSelected = selectedItems.slice(0); if (Array.isArray(value)) { @@ -465,20 +396,20 @@ export function useListSelect(items) { export function useKeyDown(targetKey, [keydownHandler, keyupHandler] = []) { const [keyIsDown, setKeyDown] = useState(false); - const handleKeyDown = e => { - if (e.key !== targetKey) return; - if (keydownHandler) keydownHandler(e); + useEffect(() => { + const handleKeyDown = e => { + if (e.key !== targetKey) return; + if (keydownHandler) keydownHandler(e); - setKeyDown(true); - }; - const handleKeyUp = e => { - if (e.key !== targetKey) return; - if (keyupHandler) keyupHandler(e); + setKeyDown(true); + }; + const handleKeyUp = e => { + if (e.key !== targetKey) return; + if (keyupHandler) keyupHandler(e); - setKeyDown(false); - }; + setKeyDown(false); + }; - useEffect(() => { document.addEventListener('keydown', handleKeyDown, { isPassive: true }); document.addEventListener('keyup', handleKeyUp, { isPassive: true }); @@ -486,7 +417,7 @@ export function useKeyDown(targetKey, [keydownHandler, keyupHandler] = []) { document.removeEventListener('keydown', handleKeyDown, { isPassive: true }); document.removeEventListener('keyup', handleKeyUp, { isPassive: true }); }; - }); + }, [keydownHandler, keyupHandler]); return keyIsDown; } diff --git a/packages/app-admin-ui/client/pages/List/index.js b/packages/app-admin-ui/client/pages/List/index.js index 7f1aca65e20..7967af2a236 100644 --- a/packages/app-admin-ui/client/pages/List/index.js +++ b/packages/app-admin-ui/client/pages/List/index.js @@ -2,7 +2,7 @@ import { jsx } from '@emotion/core'; import { Fragment, useEffect, Suspense } from 'react'; -import { useQuery } from '@apollo/react-hooks'; +import { useHistory, useLocation } from 'react-router-dom'; import { useList } from '../../providers/List'; import { IconButton } from '@arch-ui/button'; @@ -36,34 +36,19 @@ import { captureSuspensePromises } from '@keystonejs/utils'; import { useAdminMeta } from '../../providers/AdminMeta'; export function ListLayout(props) { - const { items, itemCount, queryErrors, routeProps, query } = props; - const { list, openCreateItemModal } = useList(); - const { urlState } = useListUrlState(list.key); - const { filters } = useListFilter(list.key); - const [sortBy, handleSortChange] = useListSort(list.key); + const { items, itemCount, queryErrors, query } = props; - const { adminPath } = useAdminMeta(); - const { history, location } = routeProps; - const { currentPage, fields, pageSize, search } = urlState; + const { list, openCreateItemModal } = useList(); + const { + urlState: { currentPage, fields, pageSize, search }, + } = useListUrlState(list); + const { filters } = useListFilter(); + const [sortBy, handleSortChange] = useListSort(); const [selectedItems, onSelectChange] = useListSelect(items); - // Mount with Persisted Search - // ------------------------------ - useEffect(() => { - const maybePersistedSearch = list.getPersistedSearch(); - - if (location.search) { - if (location.search !== maybePersistedSearch) { - list.setPersistedSearch(location.search); - } - } else if (maybePersistedSearch) { - history.replace({ - ...location, - search: maybePersistedSearch, - }); - } - }, []); + const { adminPath } = useAdminMeta(); + const history = useHistory(); // Misc. // ------------------------------ @@ -163,14 +148,13 @@ export function ListLayout(props) { {sortBy ? ( sorted by - + ) : ( '' )} with (