Skip to content

Commit

Permalink
feat: user preferences (#195)
Browse files Browse the repository at this point in the history
* feat: adds preferences to rest api and graphql

* feat: admin panel saves user preferences on locales

* feat: admin panel saves user column preferences for collection lists

* feat: adds new id field to blocks and array items

* feat: exposes new DocumentInfo context and usePreferences hooks to admin panel

* docs: preferences api documentation and useage details

Co-authored-by: James <james@trbl.design>
  • Loading branch information
DanRibbens and jmikrut committed Jun 21, 2021
1 parent dd40ab0 commit fb60bc7
Show file tree
Hide file tree
Showing 68 changed files with 2,447 additions and 1,419 deletions.
3 changes: 2 additions & 1 deletion .eslintrc.js
Expand Up @@ -42,7 +42,8 @@ module.exports = {
},
],
rules: {
"import/no-extraneous-dependencies": ["error", { "packageDir": "./" }],
'no-sparse-arrays': 'off',
'import/no-extraneous-dependencies': ["error", { "packageDir": "./" }],
'react/jsx-filename-extension': [2, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }],
'import/prefer-default-export': 'off',
'react/prop-types': 'off',
Expand Down
1 change: 1 addition & 0 deletions components/preferences.ts
@@ -0,0 +1 @@
export { usePreferences } from '../dist/admin/components/utilities/Preferences';
2 changes: 1 addition & 1 deletion docs/admin/overview.mdx
Expand Up @@ -71,6 +71,6 @@ This is totally possible. For the above scenario, by specifying `admin: { user:

If you would like to restrict which users from a single Collection can access the Admin panel, you can use the `admin` access control function. [Click here](/docs/access-control/overview#admin) to learn more.

### License enforcement
## License enforcement

Payload requires a valid license key to be used on production domains. You can use it as much as you'd like locally and on staging / UAT domains, but when you deploy to production, you'll need a license key to activate Payload's Admin panel. For more information, [click here](/docs/production/licensing).
157 changes: 157 additions & 0 deletions docs/admin/preferences.mdx
@@ -0,0 +1,157 @@
---
title: Managing User Preferences
label: Preferences
order: 40
desc: Store the preferences of your users as they interact with the Admin panel.
keywords: admin, preferences, custom, customize, documentation, Content Management System, cms, headless, javascript, node, react, express
---

As your users interact with your Admin panel, you might want to store their preferences in a persistent manner, so that when they revisit the Admin panel, they can pick right back up where they left off.

Out of the box, Payload handles the persistence of your users' preferences in a handful of ways, including:

1. Collection `List` view active columns, and their order, that users define
1. Their last active locale
1. The "collapsed" state of blocks, on a document level, as users edit or interact with documents

<Banner type="warning">
<strong>Important:</strong><br/>
All preferences are stored on an individual user basis. Payload automatically recognizes the user that is reading or setting a preference via all provided authentication methods.
</Banner>

### Use cases

This API is used significantly for internal operations of the Admin panel, as mentioned above. But, if you're building your own React components for use in the Admin panel, you can allow users to set their own preferences in correspondence to their usage of your components. For example:

- If you have built a "color picker", you could "remember" the last used colors that the user has set for easy access next time
- If you've built a custom `Nav` component, and you've built in an "accordion-style" UI, you might want to store the `collapsed` state of each Nav collapsible item. This way, if an editor returns to the panel, their `Nav` state is persisted automatically
- You might want to store `recentlyAccessed` documents to give admin editors an easy shortcut back to their recently accessed documents on the `Dashboard` or similar
- Many other use cases exist. Invent your own! Give your editors an intelligent and persistent editing experience.

### Database

Payload automatically creates an internally used `_preferences` collection that stores user preferences. Each document in the `_preferences` collection contains the following shape:

| Key | Value |
| -------------------- | -------------|
| `id` | A unique ID for each preference stored. |
| `key` | A unique `key` that corresponds to the preference. |
| `user` | The ID of the `user` that is storing its preference. |
| `userCollection` | The `slug` of the collection that the `user` is logged in as. |
| `value` | The value of the preference. Can be any data shape that you need. |
| `createdAt` | A timestamp of when the preference was created. |
| `updatedAt` | A timestamp set to the last time the preference was updated.

### APIs

Preferences are available to both [GraphQL](/docs/graphql/overview#preferences) and [REST](/docs/rest-api/overview#) APIs.

### Adding or reading Preferences in your own components

The Payload admin panel offers a `usePreferences` hook. The hook is only meant for use within the admin panel itself. It provides you with two methods:

##### `getPreference`

This async method provides an easy way to retrieve a user's preferences by `key`. It will return a promise containing the resulting preference value.

**Arguments**

- `key`: the `key` of your preference to retrieve.

##### `setPreference`

Also async, this method provides you with an easy way to set a user preference. It returns `void`.

**Arguments:**

- `key`: the `key` of your preference to set.
- `value`: the `value` of your preference that you're looking to set.

## Example

Here is an example for how you can utilize `usePreferences` within your custom Admin panel components. Note - this example is not fully useful and is more just a reference for how to utilize the Preferences API. In this case, we are demonstrating how to set and retrieve a user's last used colors history within a `ColorPicker` or similar type component.

```
import React, { Fragment, useState, useEffect, useCallback } from 'react';
import { usePreferences } from 'payload/components/preferences';
const lastUsedColorsPreferenceKey = 'last-used-colors';
const CustomComponent = (props) => {
const { getPreference, setPreference } = usePreferences();
// Store the last used colors in local state
const [lastUsedColors, setLastUsedColors] = useState([]);
// Callback to add a color to the last used colors
const updateLastUsedColors = useCallback((color) => {
// First, check if color already exists in last used colors.
// If it already exists, there is no need to update preferences
const colorAlreadyExists = lastUsedColors.indexOf(color) > -1;
if (!colorAlreadyExists) {
const newLastUsedColors = [
...lastUsedColors,
color,
];
setLastUsedColors(newLastUsedColors);
setPreference(lastUsedColorsPreferenceKey, newLastUsedColors);
}
}, [lastUsedColors, setPreference]);
// Retrieve preferences on component mount
// This will only be run one time, because the `getPreference` method never changes
useEffect(() => {
const asyncGetPreference = async () => {
const lastUsedColorsFromPreferences = await getPreference(lastUsedColorsPreferenceKey);
setLastUsedColors(lastUsedColorsFromPreferences);
};
asyncGetPreference();
}, [getPreference]);
return (
<div>
<button
type="button"
onClick={() => updateLastUsedColors('red')}
>
Use red
</button>
<button
type="button"
onClick={() => updateLastUsedColors('blue')}
>
Use blue
</button>
<button
type="button"
onClick={() => updateLastUsedColors('purple')}
>
Use purple
</button>
<button
type="button"
onClick={() => updateLastUsedColors('yellow')}
>
Use yellow
</button>
{lastUsedColors && (
<Fragment>
<h5>Last used colors:</h5>
<ul>
{lastUsedColors?.map((color) => (
<li key={color}>
{color}
</li>
))}
</ul>
</Fragment>
)}
</div>
);
};
export default CustomComponent;
```
2 changes: 1 addition & 1 deletion docs/admin/webpack.mdx
@@ -1,7 +1,7 @@
---
title: Webpack
label: Webpack
order: 40
order: 50
desc: The Payload admin panel uses Webpack 5 and supports many common functionalities such as SCSS and Typescript out of the box to give you more freedom.
keywords: admin, webpack, documentation, Content Management System, cms, headless, javascript, node, react, express
---
Expand Down
25 changes: 21 additions & 4 deletions docs/graphql/overview.mdx
Expand Up @@ -12,7 +12,7 @@ By default, the GraphQL API is exposed via `/api/graphql`, but you can customize

The labels you provide for your Collections and Globals are used to name the GraphQL types that are created to correspond to your config. Special characters and spaces are removed.

### Collections
## Collections

Everything that can be done to a Collection via the REST or Local API can be done with GraphQL (outside of uploading files, which is REST-only). If you have a collection as follows:

Expand Down Expand Up @@ -53,7 +53,7 @@ const PublicUser = {
| **`logoutPublicUser`** | `logout` auth operation |
| **`refreshTokenPublicUser`** | `refresh` auth operation |

### Globals
## Globals

Globals are also fully supported. For example:

Expand All @@ -78,7 +78,24 @@ const Header = {
| ---------------------- | -------------|
| **`updateHeader`** | `update` |

### GraphQL Playground
## Preferences

User [preferences](/docs/admin/overview#preferences) for the admin panel are also available to GraphQL. To query preferences you must supply an authorization token in the header and only the preferences of that user will be accessible and of the `key` argument.

**Payload will open the following query:**

| Query Name | Operation |
| ---------------------- | -------------|
| **`Preference`** | `findOne` |

**And the following mutations:**

| Query Name | Operation |
| ---------------------- | -------------|
| **`updatePreference`** | `update` |
| **`deletePreference`** | `delete` |

## GraphQL Playground

GraphQL Playground is enabled by default for development purposes, but disabled in production. You can enable it in production by passing `graphQL.disablePlaygroundInProduction` a `false` setting in the main Payload config.

Expand All @@ -89,6 +106,6 @@ You can even log in using the `login[collection-singular-label-here]` mutation t
To see more regarding how the above queries and mutations are used, visit your GraphQL playground (by default at <a href="http://localhost:3000/api/graphql-playground">(http://localhost:3000/api/graphql-playground)</a> while your server is running. There, you can use the "Schema" and "Docs" buttons on the right to see a ton of detail about how GraphQL operates within Payload.
</Banner>

### Query complexity limits
## Query complexity limits

Payload comes with a built-in query complexity limiter to prevent bad people from trying to slow down your server by running massive queries. To learn more, [click here](/docs/production/preventing-abuse#limiting-graphql-complexity).
10 changes: 10 additions & 0 deletions docs/rest-api/overview.mdx
Expand Up @@ -62,3 +62,13 @@ Globals cannot be created or deleted, so there are only two REST endpoints opene
| -------- | --------------------------- | ----------------------- |
| `GET` | `/api/globals/{globalSlug}` | Get a global by slug |
| `POST` | `/api/globals/{globalSlug}` | Update a global by slug |

## Preferences

In addition to the dynamically generated endpoints above Payload also has REST endpoints to manage the admin user [preferences](/docs/admin#preferences) for data specific to the authenticated user.

| Method | Path | Description |
| -------- | --------------------------- | ----------------------- |
| `GET` | `/api/_preferences/{key}` | Get a preference by key |
| `POST` | `/api/_preferences/{key}` | Create or update by key |
| `DELETE` | `/api/_preferences/{key}` | Delete a user preference by key |
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -91,6 +91,7 @@
"babel-jest": "^26.3.0",
"babel-loader": "^8.1.0",
"body-parser": "^1.19.0",
"bson-objectid": "^2.0.1",
"compression": "^1.7.4",
"connect-history-api-fallback": "^1.6.0",
"css-loader": "^5.0.1",
Expand Down
59 changes: 29 additions & 30 deletions src/admin/components/elements/ColumnSelector/index.tsx
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useReducer } from 'react';
import React, { useState, useEffect } from 'react';
import getInitialState from './getInitialState';
import flattenTopLevelFields from '../../../../utilities/flattenTopLevelFields';
import { usePreferences } from '../../utilities/Preferences';
import Pill from '../Pill';
import Plus from '../../icons/Plus';
import X from '../../icons/X';
Expand All @@ -10,23 +11,6 @@ import './index.scss';

const baseClass = 'column-selector';

const reducer = (state, { type, payload }) => {
if (type === 'enable') {
return [
...state,
payload,
];
}

if (type === 'replace') {
return [
...payload,
];
}

return state.filter((remainingColumn) => remainingColumn !== payload);
};

const ColumnSelector: React.FC<Props> = (props) => {
const {
collection,
Expand All @@ -39,30 +23,45 @@ const ColumnSelector: React.FC<Props> = (props) => {
handleChange,
} = props;

const [initialColumns, setInitialColumns] = useState([]);
const [fields] = useState(() => flattenTopLevelFields(collection.fields));
const [columns, dispatchColumns] = useReducer(reducer, initialColumns);

useEffect(() => {
if (typeof handleChange === 'function') handleChange(columns);
}, [columns, handleChange]);
const [columns, setColumns] = useState(() => {
const { columns: initializedColumns } = getInitialState(fields, useAsTitle, defaultColumns);
return initializedColumns;
});
const { setPreference, getPreference } = usePreferences();
const preferenceKey = `${collection.slug}-list-columns`;

useEffect(() => {
const { columns: initializedColumns } = getInitialState(fields, useAsTitle, defaultColumns);
setInitialColumns(initializedColumns);
}, [fields, useAsTitle, defaultColumns]);
(async () => {
const columnPreference: string[] = await getPreference<string[]>(preferenceKey);
if (columnPreference) {
// filter invalid columns to clean up removed fields
const filteredColumnPreferences = columnPreference.filter((preference: string) => fields.find((field) => (field.name === preference)));
if (filteredColumnPreferences.length > 0) setColumns(filteredColumnPreferences);
}
})();
}, [fields, getPreference, preferenceKey]);

useEffect(() => {
dispatchColumns({ payload: initialColumns, type: 'replace' });
}, [initialColumns]);
if (typeof handleChange === 'function') handleChange(columns);
}, [columns, handleChange]);

return (
<div className={baseClass}>
{fields && fields.map((field, i) => {
const isEnabled = columns.find((column) => column === field.name);
return (
<Pill
onClick={() => dispatchColumns({ payload: field.name, type: isEnabled ? 'disable' : 'enable' })}
onClick={() => {
let newState = [...columns];
if (isEnabled) {
newState = newState.filter((remainingColumn) => remainingColumn !== field.name);
} else {
newState.unshift(field.name);
}
setColumns(newState);
setPreference(preferenceKey, newState);
}}
alignIcon="left"
key={field.name || i}
icon={isEnabled ? <X /> : <Plus />}
Expand Down
2 changes: 1 addition & 1 deletion src/admin/components/forms/DraggableSection/index.scss
Expand Up @@ -36,7 +36,7 @@
background-color: white;
}

&--is-closed {
&--is-collapsed {
transform: rotate(0turn);
}
}
Expand Down

0 comments on commit fb60bc7

Please sign in to comment.