feat: user preferences (#195)
* 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>
This commit is contained in:
@@ -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',
|
||||
|
||||
1
components/preferences.ts
Normal file
1
components/preferences.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { usePreferences } from '../dist/admin/components/utilities/Preferences';
|
||||
@@ -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
docs/admin/preferences.mdx
Normal file
157
docs/admin/preferences.mdx
Normal file
@@ -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;
|
||||
```
|
||||
@@ -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
|
||||
---
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -53,7 +53,7 @@ const PublicUser = {
|
||||
| **`logoutPublicUser`** | `logout` auth operation |
|
||||
| **`refreshTokenPublicUser`** | `refresh` auth operation |
|
||||
|
||||
### Globals
|
||||
## Globals
|
||||
|
||||
Globals are also fully supported. For example:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
const [columns, setColumns] = useState(() => {
|
||||
const { columns: initializedColumns } = getInitialState(fields, useAsTitle, defaultColumns);
|
||||
return initializedColumns;
|
||||
});
|
||||
const { setPreference, getPreference } = usePreferences();
|
||||
const preferenceKey = `${collection.slug}-list-columns`;
|
||||
|
||||
useEffect(() => {
|
||||
(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(() => {
|
||||
if (typeof handleChange === 'function') handleChange(columns);
|
||||
}, [columns, handleChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const { columns: initializedColumns } = getInitialState(fields, useAsTitle, defaultColumns);
|
||||
setInitialColumns(initializedColumns);
|
||||
}, [fields, useAsTitle, defaultColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatchColumns({ payload: initialColumns, type: 'replace' });
|
||||
}, [initialColumns]);
|
||||
|
||||
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 />}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
&--is-closed {
|
||||
&--is-collapsed {
|
||||
transform: rotate(0turn);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { NegativeFieldGutterProvider } from '../FieldTypeGutter/context';
|
||||
import FieldTypeGutter from '../FieldTypeGutter';
|
||||
import RenderFields, { useRenderedFields } from '../RenderFields';
|
||||
import { Props } from './types';
|
||||
import HiddenInput from '../field-types/HiddenInput';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -27,10 +28,10 @@ const DraggableSection: React.FC<Props> = (props) => {
|
||||
label,
|
||||
blockType,
|
||||
fieldTypes,
|
||||
toggleRowCollapse,
|
||||
id,
|
||||
setRowCollapse,
|
||||
isCollapsed,
|
||||
permissions,
|
||||
isOpen,
|
||||
readOnly,
|
||||
hasMaxRows,
|
||||
} = props;
|
||||
@@ -40,7 +41,7 @@ const DraggableSection: React.FC<Props> = (props) => {
|
||||
|
||||
const classes = [
|
||||
baseClass,
|
||||
isOpen ? 'is-open' : 'is-closed',
|
||||
isCollapsed ? 'is-collapsed' : 'is-open',
|
||||
(isHovered && !readOnly) && 'is-hovered',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
@@ -76,6 +77,11 @@ const DraggableSection: React.FC<Props> = (props) => {
|
||||
|
||||
{blockType === 'blocks' && (
|
||||
<div className={`${baseClass}__section-header`}>
|
||||
<HiddenInput
|
||||
name={`${parentPath}.${rowIndex}.id`}
|
||||
value={id}
|
||||
modifyForm={false}
|
||||
/>
|
||||
<SectionTitle
|
||||
label={label}
|
||||
path={`${parentPath}.${rowIndex}.blockName`}
|
||||
@@ -84,17 +90,17 @@ const DraggableSection: React.FC<Props> = (props) => {
|
||||
|
||||
<Button
|
||||
icon="chevron"
|
||||
onClick={() => toggleRowCollapse(rowIndex)}
|
||||
onClick={() => setRowCollapse(id, !isCollapsed)}
|
||||
buttonStyle="icon-label"
|
||||
className={`toggle-collapse toggle-collapse--is-${isOpen ? 'open' : 'closed'}`}
|
||||
className={`toggle-collapse toggle-collapse--is-${isCollapsed ? 'collapsed' : 'open'}`}
|
||||
round
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimateHeight
|
||||
height={isOpen ? 'auto' : 0}
|
||||
duration={0}
|
||||
height={isCollapsed ? 0 : 'auto'}
|
||||
duration={200}
|
||||
>
|
||||
<NegativeFieldGutterProvider allow={false}>
|
||||
<RenderFields
|
||||
|
||||
@@ -13,12 +13,12 @@ export type Props = {
|
||||
label?: string
|
||||
blockType?: string
|
||||
fieldTypes: FieldTypes
|
||||
toggleRowCollapse?: (index: number) => void
|
||||
id: string
|
||||
isCollapsed?: boolean
|
||||
setRowCollapse?: (id: string, open: boolean) => void
|
||||
positionPanelVerticalAlignment?: 'top' | 'center' | 'sticky'
|
||||
actionPanelVerticalAlignment?: 'top' | 'center' | 'sticky'
|
||||
permissions: FieldPermissions
|
||||
isOpen?: boolean
|
||||
readOnly: boolean
|
||||
blocks?: Block[]
|
||||
hasMaxRows?: boolean
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ObjectID from 'bson-objectid';
|
||||
import { Field as FieldSchema } from '../../../../fields/config/types';
|
||||
import { Fields, Field, Data } from './types';
|
||||
|
||||
@@ -64,10 +65,19 @@ const buildStateFromSchema = async (fieldSchema: FieldSchema[], fullData: Data =
|
||||
if (field.type === 'array') {
|
||||
return {
|
||||
...state,
|
||||
...rows.reduce((rowState, row, i) => ({
|
||||
...rowState,
|
||||
...iterateFields(field.fields, row, `${path}${field.name}.${i}.`),
|
||||
}), {}),
|
||||
...rows.reduce((rowState, row, i) => {
|
||||
const rowPath = `${path}${field.name}.${i}.`;
|
||||
|
||||
return {
|
||||
...rowState,
|
||||
[`${rowPath}id`]: {
|
||||
value: row.id,
|
||||
initialValue: row.id || new ObjectID().toHexString(),
|
||||
valid: true,
|
||||
},
|
||||
...iterateFields(field.fields, row, rowPath),
|
||||
};
|
||||
}, {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,7 +87,6 @@ const buildStateFromSchema = async (fieldSchema: FieldSchema[], fullData: Data =
|
||||
...rows.reduce((rowState, row, i) => {
|
||||
const block = field.blocks.find((blockType) => blockType.slug === row.blockType);
|
||||
const rowPath = `${path}${field.name}.${i}.`;
|
||||
|
||||
return {
|
||||
...rowState,
|
||||
[`${rowPath}blockType`]: {
|
||||
@@ -90,6 +99,11 @@ const buildStateFromSchema = async (fieldSchema: FieldSchema[], fullData: Data =
|
||||
initialValue: row.blockName,
|
||||
valid: true,
|
||||
},
|
||||
[`${rowPath}id`]: {
|
||||
value: row.id,
|
||||
initialValue: row.id || new ObjectID().toHexString(),
|
||||
valid: true,
|
||||
},
|
||||
...(block?.fields ? iterateFields(block.fields, row, rowPath) : {}),
|
||||
};
|
||||
}, {}),
|
||||
|
||||
@@ -71,12 +71,6 @@ function fieldReducer(state: Fields, action): Fields {
|
||||
initialValue: blockType,
|
||||
valid: true,
|
||||
};
|
||||
|
||||
subFieldState.blockName = {
|
||||
value: null,
|
||||
initialValue: null,
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Add new object containing subfield names to unflattenedRows array
|
||||
|
||||
@@ -19,6 +19,7 @@ const getDataByPath = (fields: Fields, path: string): unknown => {
|
||||
|
||||
const values = reduceFieldsToValues(data, true);
|
||||
const unflattenedData = unflatten(values);
|
||||
|
||||
return unflattenedData?.[name];
|
||||
};
|
||||
|
||||
|
||||
@@ -269,7 +269,6 @@ const Form: React.FC<Props> = (props) => {
|
||||
waitForAutocomplete,
|
||||
]);
|
||||
|
||||
|
||||
const getFields = useCallback(() => contextRef.current.fields, [contextRef]);
|
||||
const getField = useCallback((path: string) => contextRef.current.fields[path], [contextRef]);
|
||||
const getData = useCallback(() => reduceFieldsToValues(contextRef.current.fields, true), [contextRef]);
|
||||
|
||||
@@ -20,6 +20,10 @@ export type Data = {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type Preferences = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
disabled?: boolean
|
||||
onSubmit?: (fields: Fields, data: Data) => void
|
||||
|
||||
@@ -183,11 +183,10 @@ const RenderArray = React.memo((props: RenderArrayProps) => {
|
||||
{rows.length > 0 && rows.map((row, i) => (
|
||||
<DraggableSection
|
||||
readOnly={readOnly}
|
||||
key={row.key}
|
||||
id={row.key}
|
||||
key={row.id}
|
||||
id={row.id}
|
||||
blockType="array"
|
||||
label={labels.singular}
|
||||
isOpen={row.open}
|
||||
rowCount={rows.length}
|
||||
rowIndex={i}
|
||||
addRow={addRow}
|
||||
|
||||
@@ -3,10 +3,11 @@ import React, {
|
||||
} from 'react';
|
||||
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
|
||||
|
||||
import { classes } from 'http-status';
|
||||
import { usePreferences } from '../../../utilities/Preferences';
|
||||
import withCondition from '../../withCondition';
|
||||
import Button from '../../../elements/Button';
|
||||
import reducer from '../rowReducer';
|
||||
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
|
||||
import { useForm } from '../../Form/context';
|
||||
import buildStateFromSchema from '../../Form/buildStateFromSchema';
|
||||
import DraggableSection from '../../DraggableSection';
|
||||
@@ -17,6 +18,7 @@ import BlockSelector from './BlockSelector';
|
||||
import { blocks as blocksValidator } from '../../../../../fields/validations';
|
||||
import Banner from '../../../elements/Banner';
|
||||
import { Props, RenderBlockProps } from './types';
|
||||
import { DocumentPreferences } from '../../../../../preferences/types';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -48,6 +50,9 @@ const Blocks: React.FC<Props> = (props) => {
|
||||
|
||||
const path = pathFromProps || name;
|
||||
|
||||
const { preferencesKey } = useDocumentInfo();
|
||||
|
||||
const { getPreference, setPreference } = usePreferences();
|
||||
const [rows, dispatchRows] = useReducer(reducer, []);
|
||||
const formContext = useForm();
|
||||
const { dispatchFields } = formContext;
|
||||
@@ -98,9 +103,34 @@ const Blocks: React.FC<Props> = (props) => {
|
||||
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path });
|
||||
}, [dispatchRows, dispatchFields, path]);
|
||||
|
||||
const toggleCollapse = useCallback((rowIndex) => {
|
||||
dispatchRows({ type: 'TOGGLE_COLLAPSE', rowIndex });
|
||||
}, []);
|
||||
const setCollapse = useCallback(async (id: string, collapsed: boolean) => {
|
||||
dispatchRows({ type: 'SET_COLLAPSE', id, collapsed });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferences: DocumentPreferences = await getPreference(preferencesKey);
|
||||
const preferencesToSet = preferences || { fields: { } };
|
||||
let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed
|
||||
.filter((filterID) => (rows.find((row) => row.id === filterID)))
|
||||
|| [];
|
||||
|
||||
if (!collapsed) {
|
||||
newCollapsedState = newCollapsedState.filter((existingID) => existingID !== id);
|
||||
} else {
|
||||
newCollapsedState.push(id);
|
||||
}
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: newCollapsedState,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [preferencesKey, getPreference, path, setPreference, rows]);
|
||||
|
||||
const onDragEnd = useCallback((result) => {
|
||||
if (!result.destination) return;
|
||||
@@ -110,9 +140,14 @@ const Blocks: React.FC<Props> = (props) => {
|
||||
}, [moveRow]);
|
||||
|
||||
useEffect(() => {
|
||||
const data = formContext.getDataByPath(path);
|
||||
dispatchRows({ type: 'SET_ALL', data: data || [] });
|
||||
}, [formContext, path]);
|
||||
const fetchPreferences = async () => {
|
||||
const preferences = preferencesKey ? await getPreference<DocumentPreferences>(preferencesKey) : undefined;
|
||||
const data = formContext.getDataByPath(path);
|
||||
dispatchRows({ type: 'SET_ALL', data: data || [], collapsedState: preferences?.fields?.[path]?.collapsed });
|
||||
};
|
||||
|
||||
fetchPreferences();
|
||||
}, [formContext, path, preferencesKey, getPreference]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(rows?.length || 0);
|
||||
@@ -138,7 +173,7 @@ const Blocks: React.FC<Props> = (props) => {
|
||||
path={path}
|
||||
name={name}
|
||||
fieldTypes={fieldTypes}
|
||||
toggleCollapse={toggleCollapse}
|
||||
setCollapse={setCollapse}
|
||||
permissions={permissions}
|
||||
value={value as number}
|
||||
blocks={blocks}
|
||||
@@ -165,7 +200,7 @@ const RenderBlocks = React.memo((props: RenderBlockProps) => {
|
||||
fieldTypes,
|
||||
permissions,
|
||||
value,
|
||||
toggleCollapse,
|
||||
setCollapse,
|
||||
blocks,
|
||||
readOnly,
|
||||
minRows,
|
||||
@@ -207,31 +242,24 @@ const RenderBlocks = React.memo((props: RenderBlockProps) => {
|
||||
return (
|
||||
<DraggableSection
|
||||
readOnly={readOnly}
|
||||
key={row.key}
|
||||
id={row.key}
|
||||
key={row.id}
|
||||
id={row.id}
|
||||
blockType="blocks"
|
||||
blocks={blocks}
|
||||
label={blockToRender?.labels?.singular}
|
||||
isOpen={row.open}
|
||||
isCollapsed={row.collapsed}
|
||||
rowCount={rows.length}
|
||||
rowIndex={i}
|
||||
addRow={addRow}
|
||||
removeRow={removeRow}
|
||||
moveRow={moveRow}
|
||||
toggleRowCollapse={toggleCollapse}
|
||||
setRowCollapse={setCollapse}
|
||||
parentPath={path}
|
||||
fieldTypes={fieldTypes}
|
||||
permissions={permissions}
|
||||
hasMaxRows={hasMaxRows}
|
||||
fieldSchema={[
|
||||
...blockToRender.fields,
|
||||
{
|
||||
name: 'blockType',
|
||||
type: 'text',
|
||||
admin: {
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
@@ -243,7 +271,7 @@ const RenderBlocks = React.memo((props: RenderBlockProps) => {
|
||||
<Banner type="error">
|
||||
This field requires at least
|
||||
{' '}
|
||||
{`${minRows} ${minRows === 1 ? labels.singular : labels.plural}`}
|
||||
{`${minRows || 1} ${minRows === 1 || typeof minRows === 'undefined' ? labels.singular : labels.plural}`}
|
||||
</Banner>
|
||||
)}
|
||||
{(rows.length === 0 && readOnly) && (
|
||||
|
||||
@@ -29,5 +29,5 @@ export type RenderBlockProps = {
|
||||
errorMessage: string
|
||||
rows: Data[]
|
||||
blocks: Block[],
|
||||
toggleCollapse: (row: number) => void
|
||||
setCollapse: (id: string, collapsed: boolean) => void
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const HiddenInput: React.FC<Props> = (props) => {
|
||||
name,
|
||||
path: pathFromProps,
|
||||
value: valueFromProps,
|
||||
modifyForm = true,
|
||||
} = props;
|
||||
|
||||
const path = pathFromProps || name;
|
||||
@@ -18,9 +19,9 @@ const HiddenInput: React.FC<Props> = (props) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (valueFromProps !== undefined) {
|
||||
setValue(valueFromProps);
|
||||
setValue(valueFromProps, modifyForm);
|
||||
}
|
||||
}, [valueFromProps, setValue]);
|
||||
}, [valueFromProps, setValue, modifyForm]);
|
||||
|
||||
return (
|
||||
<input
|
||||
|
||||
@@ -2,4 +2,5 @@ export type Props = {
|
||||
name: string
|
||||
path?: string
|
||||
value: unknown
|
||||
modifyForm?: boolean
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import ObjectID from 'bson-objectid';
|
||||
|
||||
const reducer = (currentState, action) => {
|
||||
const {
|
||||
type, rowIndex, moveFromIndex, moveToIndex, data, blockType,
|
||||
type, rowIndex, moveFromIndex, moveToIndex, data, blockType, collapsedState, collapsed, id,
|
||||
} = action;
|
||||
|
||||
const stateCopy = [...currentState];
|
||||
@@ -10,36 +10,34 @@ const reducer = (currentState, action) => {
|
||||
switch (type) {
|
||||
case 'SET_ALL': {
|
||||
if (Array.isArray(data)) {
|
||||
if (currentState.length !== data.length) {
|
||||
return data.map((dataRow) => {
|
||||
const row = {
|
||||
key: uuidv4(),
|
||||
open: true,
|
||||
blockType: undefined,
|
||||
};
|
||||
return data.map((dataRow, i) => {
|
||||
const row = {
|
||||
id: dataRow?.id || new ObjectID().toHexString(),
|
||||
collapsed: (collapsedState || []).includes(dataRow?.id),
|
||||
blockType: dataRow?.blockType,
|
||||
};
|
||||
|
||||
if (dataRow.blockType) {
|
||||
row.blockType = dataRow.blockType;
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
return currentState;
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
case 'TOGGLE_COLLAPSE':
|
||||
stateCopy[rowIndex].open = !stateCopy[rowIndex].open;
|
||||
case 'SET_COLLAPSE': {
|
||||
const matchedRowIndex = stateCopy.findIndex(({ id: rowID }) => rowID === id);
|
||||
|
||||
if (matchedRowIndex > -1 && stateCopy[matchedRowIndex]) {
|
||||
stateCopy[matchedRowIndex].collapsed = collapsed;
|
||||
}
|
||||
|
||||
return stateCopy;
|
||||
}
|
||||
|
||||
case 'ADD': {
|
||||
const newRow = {
|
||||
id: new ObjectID().toHexString(),
|
||||
open: true,
|
||||
key: uuidv4(),
|
||||
blockType: undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -70,10 +70,10 @@ const useFieldType = <T extends unknown>(options: Options): FieldType<T> => {
|
||||
// Method to return from `useFieldType`, used to
|
||||
// update internal field values from field component(s)
|
||||
// as fast as they arrive. NOTE - this method is NOT debounced
|
||||
const setValue = useCallback((e) => {
|
||||
const setValue = useCallback((e, modifyForm = true) => {
|
||||
const val = (e && e.target) ? e.target.value : e;
|
||||
|
||||
if (!ignoreWhileFlattening && !modified) {
|
||||
if ((!ignoreWhileFlattening && !modified) && modifyForm) {
|
||||
setModified(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,5 +16,5 @@ export type FieldType<T> = {
|
||||
showError: boolean
|
||||
formSubmitted: boolean
|
||||
formProcessing: boolean
|
||||
setValue: (val: unknown) => void
|
||||
setValue: (val: unknown, modifyForm?: boolean) => void
|
||||
}
|
||||
|
||||
63
src/admin/components/utilities/DocumentInfo/index.tsx
Normal file
63
src/admin/components/utilities/DocumentInfo/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, {
|
||||
createContext, useContext,
|
||||
} from 'react';
|
||||
|
||||
type CollectionDoc = {
|
||||
type: 'collection'
|
||||
slug: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
type GlobalDoc = {
|
||||
type: 'global'
|
||||
slug: string
|
||||
}
|
||||
|
||||
type ContextType = (CollectionDoc | GlobalDoc) & {
|
||||
preferencesKey?: string
|
||||
}
|
||||
|
||||
const Context = createContext({} as ContextType);
|
||||
|
||||
export const DocumentInfoProvider: React.FC<CollectionDoc | GlobalDoc> = (props) => {
|
||||
const { children, type, slug } = props;
|
||||
|
||||
if (type === 'global') {
|
||||
return (
|
||||
<Context.Provider value={{
|
||||
type,
|
||||
slug: props.slug,
|
||||
preferencesKey: `global-${slug}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'collection') {
|
||||
const { id } = props as CollectionDoc;
|
||||
|
||||
const value: ContextType = {
|
||||
type,
|
||||
slug,
|
||||
};
|
||||
|
||||
if (id) {
|
||||
value.id = id;
|
||||
value.preferencesKey = `collection-${slug}-${id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Context.Provider value={value}>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useDocumentInfo = (): ContextType => useContext(Context);
|
||||
|
||||
export default Context;
|
||||
@@ -1,21 +1,49 @@
|
||||
import React, {
|
||||
createContext, useContext, useState, useEffect,
|
||||
} from 'react';
|
||||
import { useConfig } from '@payloadcms/config-provider';
|
||||
import { useAuth, useConfig } from '@payloadcms/config-provider';
|
||||
import { usePreferences } from '../Preferences';
|
||||
import { useSearchParams } from '../SearchParams';
|
||||
|
||||
const Context = createContext('');
|
||||
|
||||
export const LocaleProvider: React.FC = ({ children }) => {
|
||||
const { localization } = useConfig();
|
||||
const { user } = useAuth();
|
||||
const defaultLocale = (localization && localization.defaultLocale) ? localization.defaultLocale : 'en';
|
||||
const [locale, setLocale] = useState<string>(defaultLocale);
|
||||
const { getPreference, setPreference } = usePreferences();
|
||||
const searchParams = useSearchParams();
|
||||
const localeFromParams = searchParams.locale;
|
||||
|
||||
useEffect(() => {
|
||||
if (localeFromParams && localization.locales.indexOf(localeFromParams as string) > -1) setLocale(localeFromParams as string);
|
||||
}, [localeFromParams, localization]);
|
||||
if (!localization) {
|
||||
return;
|
||||
}
|
||||
|
||||
// set locale from search param
|
||||
if (localeFromParams && localization.locales.indexOf(localeFromParams as string) > -1) {
|
||||
setLocale(localeFromParams as string);
|
||||
if (user) setPreference('locale', localeFromParams);
|
||||
return;
|
||||
}
|
||||
|
||||
// set locale from preferences or default
|
||||
(async () => {
|
||||
let preferenceLocale: string;
|
||||
let isPreferenceInConfig: boolean;
|
||||
if (user) {
|
||||
preferenceLocale = await getPreference<string>('locale');
|
||||
isPreferenceInConfig = preferenceLocale && (localization.locales.indexOf(preferenceLocale) > -1);
|
||||
if (isPreferenceInConfig) {
|
||||
setLocale(preferenceLocale);
|
||||
return;
|
||||
}
|
||||
setPreference('locale', defaultLocale);
|
||||
}
|
||||
setLocale(defaultLocale);
|
||||
})();
|
||||
}, [defaultLocale, getPreference, localeFromParams, localization, setPreference, user]);
|
||||
|
||||
return (
|
||||
<Context.Provider value={locale}>
|
||||
|
||||
67
src/admin/components/utilities/Preferences/index.tsx
Normal file
67
src/admin/components/utilities/Preferences/index.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react';
|
||||
|
||||
import { useAuth, useConfig } from '@payloadcms/config-provider';
|
||||
import { requests } from '../../../api';
|
||||
|
||||
type PreferencesContext = {
|
||||
getPreference: <T>(key: string) => T | Promise<T>;
|
||||
setPreference: <T>(key: string, value: T) => void;
|
||||
}
|
||||
|
||||
const Context = createContext({} as PreferencesContext);
|
||||
|
||||
const requestOptions = (value) => ({
|
||||
body: JSON.stringify({ value }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
export const PreferencesProvider: React.FC = ({ children }) => {
|
||||
const contextRef = useRef({} as PreferencesContext);
|
||||
const preferencesRef = useRef({});
|
||||
const config = useConfig();
|
||||
const { user } = useAuth();
|
||||
const { serverURL, routes: { api } } = config;
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
// clear preferences between users
|
||||
preferencesRef.current = {};
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const getPreference = useCallback(async (key: string) => {
|
||||
if (typeof preferencesRef.current[key] !== 'undefined') return preferencesRef.current[key];
|
||||
const promise = new Promise((resolve) => {
|
||||
(async () => {
|
||||
const request = await requests.get(`${serverURL}${api}/_preferences/${key}`);
|
||||
let value = null;
|
||||
if (request.status === 200) {
|
||||
const preference = await request.json();
|
||||
value = preference.value;
|
||||
}
|
||||
preferencesRef.current[key] = value;
|
||||
resolve(value);
|
||||
})();
|
||||
});
|
||||
preferencesRef.current[key] = promise;
|
||||
return promise;
|
||||
}, [api, preferencesRef, serverURL]);
|
||||
|
||||
const setPreference = useCallback(async (key: string, value: unknown): Promise<void> => {
|
||||
preferencesRef.current[key] = value;
|
||||
await requests.post(`${serverURL}${api}/_preferences/${key}`, requestOptions(value));
|
||||
}, [api, serverURL]);
|
||||
|
||||
contextRef.current.getPreference = getPreference;
|
||||
contextRef.current.setPreference = setPreference;
|
||||
|
||||
return (
|
||||
<Context.Provider value={contextRef.current}>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePreferences = (): PreferencesContext => useContext(Context);
|
||||
@@ -5,6 +5,7 @@ import { useStepNav } from '../../elements/StepNav';
|
||||
|
||||
import usePayloadAPI from '../../../hooks/usePayloadAPI';
|
||||
import { useLocale } from '../../utilities/Locale';
|
||||
import { DocumentInfoProvider } from '../../utilities/DocumentInfo';
|
||||
import DefaultAccount from './Default';
|
||||
import buildStateFromSchema from '../../forms/Form/buildStateFromSchema';
|
||||
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
|
||||
@@ -69,22 +70,28 @@ const AccountView: React.FC = () => {
|
||||
}, [dataToRender, fields]);
|
||||
|
||||
return (
|
||||
<NegativeFieldGutterProvider allow>
|
||||
<RenderCustomComponent
|
||||
DefaultComponent={DefaultAccount}
|
||||
CustomComponent={CustomAccount}
|
||||
componentProps={{
|
||||
action,
|
||||
data,
|
||||
collection,
|
||||
permissions: collectionPermissions,
|
||||
hasSavePermission,
|
||||
initialState,
|
||||
apiURL,
|
||||
isLoading,
|
||||
}}
|
||||
/>
|
||||
</NegativeFieldGutterProvider>
|
||||
<DocumentInfoProvider
|
||||
type="collection"
|
||||
slug={collection?.slug}
|
||||
id={user?.id}
|
||||
>
|
||||
<NegativeFieldGutterProvider allow>
|
||||
<RenderCustomComponent
|
||||
DefaultComponent={DefaultAccount}
|
||||
CustomComponent={CustomAccount}
|
||||
componentProps={{
|
||||
action,
|
||||
data,
|
||||
collection,
|
||||
permissions: collectionPermissions,
|
||||
hasSavePermission,
|
||||
initialState,
|
||||
apiURL,
|
||||
isLoading,
|
||||
}}
|
||||
/>
|
||||
</NegativeFieldGutterProvider>
|
||||
</DocumentInfoProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { useConfig, useAuth } from '@payloadcms/config-provider';
|
||||
import { useStepNav } from '../../elements/StepNav';
|
||||
import usePayloadAPI from '../../../hooks/usePayloadAPI';
|
||||
import { DocumentInfoProvider } from '../../utilities/DocumentInfo';
|
||||
|
||||
import { useLocale } from '../../utilities/Locale';
|
||||
|
||||
@@ -80,21 +81,26 @@ const GlobalView: React.FC<IndexProps> = (props) => {
|
||||
const globalPermissions = permissions?.globals?.[slug];
|
||||
|
||||
return (
|
||||
<NegativeFieldGutterProvider allow>
|
||||
<RenderCustomComponent
|
||||
DefaultComponent={DefaultGlobal}
|
||||
CustomComponent={CustomEdit}
|
||||
componentProps={{
|
||||
data: dataToRender,
|
||||
permissions: globalPermissions,
|
||||
initialState,
|
||||
global,
|
||||
onSave,
|
||||
apiURL: `${serverURL}${api}/globals/${slug}?depth=0`,
|
||||
action: `${serverURL}${api}/globals/${slug}?locale=${locale}&depth=0&fallback-locale=null`,
|
||||
}}
|
||||
/>
|
||||
</NegativeFieldGutterProvider>
|
||||
<DocumentInfoProvider
|
||||
slug={slug}
|
||||
type="global"
|
||||
>
|
||||
<NegativeFieldGutterProvider allow>
|
||||
<RenderCustomComponent
|
||||
DefaultComponent={DefaultGlobal}
|
||||
CustomComponent={CustomEdit}
|
||||
componentProps={{
|
||||
data: dataToRender,
|
||||
permissions: globalPermissions,
|
||||
initialState,
|
||||
global,
|
||||
onSave,
|
||||
apiURL: `${serverURL}${api}/globals/${slug}?depth=0`,
|
||||
action: `${serverURL}${api}/globals/${slug}?locale=${locale}&depth=0&fallback-locale=null`,
|
||||
}}
|
||||
/>
|
||||
</NegativeFieldGutterProvider>
|
||||
</DocumentInfoProvider>
|
||||
);
|
||||
};
|
||||
export default GlobalView;
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
background: white;
|
||||
padding: base(5) base(6) base(6);
|
||||
margin-bottom: base(1.5);
|
||||
overflow-x: scroll;
|
||||
overflow-x: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useStepNav } from '../../../elements/StepNav';
|
||||
import usePayloadAPI from '../../../../hooks/usePayloadAPI';
|
||||
|
||||
import RenderCustomComponent from '../../../utilities/RenderCustomComponent';
|
||||
import { DocumentInfoProvider } from '../../../utilities/DocumentInfo';
|
||||
import DefaultEdit from './Default';
|
||||
import buildStateFromSchema from '../../../forms/Form/buildStateFromSchema';
|
||||
import { NegativeFieldGutterProvider } from '../../../forms/FieldTypeGutter/context';
|
||||
@@ -42,9 +43,7 @@ const EditView: React.FC<IndexProps> = (props) => {
|
||||
|
||||
const onSave = async (json) => {
|
||||
if (!isEditing) {
|
||||
history.push(`${admin}/collections/${collection.slug}/${json?.doc?.id}`, {
|
||||
data: json.doc,
|
||||
});
|
||||
history.push(`${admin}/collections/${collection.slug}/${json?.doc?.id}`);
|
||||
} else {
|
||||
const state = await buildStateFromSchema(fields, json.doc);
|
||||
setInitialState(state);
|
||||
@@ -99,24 +98,30 @@ const EditView: React.FC<IndexProps> = (props) => {
|
||||
const hasSavePermission = (isEditing && collectionPermissions?.update?.permission) || (!isEditing && collectionPermissions?.create?.permission);
|
||||
|
||||
return (
|
||||
<NegativeFieldGutterProvider allow>
|
||||
<RenderCustomComponent
|
||||
DefaultComponent={DefaultEdit}
|
||||
CustomComponent={CustomEdit}
|
||||
componentProps={{
|
||||
isLoading,
|
||||
data: dataToRender,
|
||||
collection,
|
||||
permissions: collectionPermissions,
|
||||
isEditing,
|
||||
onSave,
|
||||
initialState,
|
||||
hasSavePermission,
|
||||
apiURL,
|
||||
action,
|
||||
}}
|
||||
/>
|
||||
</NegativeFieldGutterProvider>
|
||||
<DocumentInfoProvider
|
||||
id={id}
|
||||
slug={collection.slug}
|
||||
type="collection"
|
||||
>
|
||||
<NegativeFieldGutterProvider allow>
|
||||
<RenderCustomComponent
|
||||
DefaultComponent={DefaultEdit}
|
||||
CustomComponent={CustomEdit}
|
||||
componentProps={{
|
||||
isLoading,
|
||||
data: dataToRender,
|
||||
collection,
|
||||
permissions: collectionPermissions,
|
||||
isEditing,
|
||||
onSave,
|
||||
initialState,
|
||||
hasSavePermission,
|
||||
apiURL,
|
||||
action,
|
||||
}}
|
||||
/>
|
||||
</NegativeFieldGutterProvider>
|
||||
</DocumentInfoProvider>
|
||||
);
|
||||
};
|
||||
export default EditView;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ListControls } from '../../../elements/ListControls/types';
|
||||
import formatFields from './formatFields';
|
||||
import buildColumns from './buildColumns';
|
||||
import { ListIndexProps } from './types';
|
||||
import { usePreferences } from '../../../utilities/Preferences';
|
||||
|
||||
const ListView: React.FC<ListIndexProps> = (props) => {
|
||||
const {
|
||||
@@ -34,6 +35,7 @@ const ListView: React.FC<ListIndexProps> = (props) => {
|
||||
const { permissions } = useAuth();
|
||||
const location = useLocation();
|
||||
const { setStepNav } = useStepNav();
|
||||
const { getPreference, setPreference } = usePreferences();
|
||||
|
||||
const [fields] = useState(() => formatFields(collection));
|
||||
const [listControls, setListControls] = useState<ListControls>({});
|
||||
@@ -75,8 +77,11 @@ const ListView: React.FC<ListIndexProps> = (props) => {
|
||||
}, [setStepNav, plural]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumns(buildColumns(collection, listControlsColumns, setSort));
|
||||
}, [collection, listControlsColumns, setSort]);
|
||||
(async () => {
|
||||
const columnPreferences = await getPreference<string[]>(`${collection.slug}-list-columns`);
|
||||
setColumns(buildColumns(collection, columnPreferences || listControlsColumns, setSort));
|
||||
})();
|
||||
}, [collection, listControlsColumns, setSort, getPreference]);
|
||||
|
||||
return (
|
||||
<RenderCustomComponent
|
||||
|
||||
@@ -9,6 +9,7 @@ import { WindowInfoProvider } from '@faceless-ui/window-info';
|
||||
import { ModalProvider, ModalContainer } from '@faceless-ui/modal';
|
||||
import { ToastContainer, Slide } from 'react-toastify';
|
||||
import { ConfigProvider, AuthProvider } from '@payloadcms/config-provider';
|
||||
import { PreferencesProvider } from './components/utilities/Preferences';
|
||||
import { SearchParamsProvider } from './components/utilities/SearchParams';
|
||||
import { LocaleProvider } from './components/utilities/Locale';
|
||||
import Routes from './components/Routes';
|
||||
@@ -32,12 +33,14 @@ const Index = () => (
|
||||
zIndex={50}
|
||||
>
|
||||
<AuthProvider>
|
||||
<SearchParamsProvider>
|
||||
<LocaleProvider>
|
||||
<Routes />
|
||||
</LocaleProvider>
|
||||
</SearchParamsProvider>
|
||||
<ModalContainer />
|
||||
<PreferencesProvider>
|
||||
<SearchParamsProvider>
|
||||
<LocaleProvider>
|
||||
<Routes />
|
||||
</LocaleProvider>
|
||||
</SearchParamsProvider>
|
||||
<ModalContainer />
|
||||
</PreferencesProvider>
|
||||
</AuthProvider>
|
||||
</ModalProvider>
|
||||
</Router>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AuthenticationError, LockedAuth } from '../../errors';
|
||||
import { PayloadRequest } from '../../express/types';
|
||||
import getCookieExpiration from '../../utilities/getCookieExpiration';
|
||||
import isLocked from '../isLocked';
|
||||
import removeInternalFields from '../../utilities/removeInternalFields';
|
||||
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
|
||||
import { Field, fieldHasSubFields } from '../../fields/config/types';
|
||||
import { User } from '../types';
|
||||
import { Collection } from '../../collections/config/types';
|
||||
@@ -100,8 +100,8 @@ async function login(incomingArgs: Arguments): Promise<Result> {
|
||||
}
|
||||
|
||||
let user = userDoc.toJSON({ virtuals: true });
|
||||
user = removeInternalFields(user);
|
||||
user = JSON.parse(JSON.stringify(user));
|
||||
user = sanitizeInternalFields(user);
|
||||
|
||||
const fieldsToSign = collectionConfig.fields.reduce((signedFields, field: Field) => {
|
||||
const result = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import merge from 'deepmerge';
|
||||
import { CollectionConfig, PayloadCollectionConfig } from './types';
|
||||
import sanitizeFields from '../../fields/config/sanitize';
|
||||
import toKebabCase from '../../utilities/toKebabCase';
|
||||
import baseAuthFields from '../../fields/baseFields/baseFields';
|
||||
import baseAuthFields from '../../fields/baseFields/baseAuthFields';
|
||||
import baseAPIKeyFields from '../../fields/baseFields/baseAPIKeyFields';
|
||||
import baseVerificationFields from '../../fields/baseFields/baseVerificationFields';
|
||||
import baseAccountLockFields from '../../fields/baseFields/baseAccountLockFields';
|
||||
|
||||
@@ -4,7 +4,7 @@ import crypto from 'crypto';
|
||||
|
||||
import { UploadedFile } from 'express-fileupload';
|
||||
import executeAccess from '../../auth/executeAccess';
|
||||
import removeInternalFields from '../../utilities/removeInternalFields';
|
||||
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
|
||||
|
||||
import { MissingFile, FileUploadError, ValidationError } from '../../errors';
|
||||
import resizeAndSave from '../../uploads/imageResizer';
|
||||
@@ -214,9 +214,9 @@ async function create(this: Payload, incomingArgs: Arguments): Promise<Document>
|
||||
let result: Document = doc.toJSON({ virtuals: true });
|
||||
const verificationToken = result._verificationToken;
|
||||
|
||||
result = removeInternalFields(result);
|
||||
result = JSON.stringify(result);
|
||||
result = JSON.parse(result);
|
||||
result = sanitizeInternalFields(result);
|
||||
|
||||
// /////////////////////////////////////
|
||||
// afterChange - Fields
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { PayloadRequest } from '../../express/types';
|
||||
import removeInternalFields from '../../utilities/removeInternalFields';
|
||||
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
|
||||
import { NotFound, Forbidden, ErrorDeletingFile } from '../../errors';
|
||||
import executeAccess from '../../auth/executeAccess';
|
||||
import fileExists from '../../uploads/fileExists';
|
||||
@@ -135,9 +135,18 @@ async function deleteQuery(incomingArgs: Arguments): Promise<Document> {
|
||||
|
||||
let result: Document = doc.toJSON({ virtuals: true });
|
||||
|
||||
result = removeInternalFields(result);
|
||||
result = JSON.stringify(result);
|
||||
result = JSON.parse(result);
|
||||
result = sanitizeInternalFields(result);
|
||||
|
||||
// /////////////////////////////////////
|
||||
// Delete Preferences
|
||||
// /////////////////////////////////////
|
||||
|
||||
if (collectionConfig.auth) {
|
||||
await this.preferences.Model.deleteMany({ user: id, userCollection: collectionConfig.slug });
|
||||
}
|
||||
await this.preferences.Model.deleteMany({ key: `collection-${collectionConfig.slug}-${id}` });
|
||||
|
||||
// /////////////////////////////////////
|
||||
// afterDelete - Collection
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Where } from '../../types';
|
||||
import { PayloadRequest } from '../../express/types';
|
||||
import executeAccess from '../../auth/executeAccess';
|
||||
import removeInternalFields from '../../utilities/removeInternalFields';
|
||||
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
|
||||
import { Collection, PaginatedDocs } from '../config/types';
|
||||
import { hasWhereAccessResult } from '../../auth/types';
|
||||
|
||||
@@ -180,7 +180,7 @@ async function find(incomingArgs: Arguments): Promise<PaginatedDocs> {
|
||||
|
||||
result = {
|
||||
...result,
|
||||
docs: result.docs.map((doc) => removeInternalFields(doc)),
|
||||
docs: result.docs.map((doc) => sanitizeInternalFields(doc)),
|
||||
};
|
||||
|
||||
return result;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import memoize from 'micro-memoize';
|
||||
import { PayloadRequest } from '../../express/types';
|
||||
import { Collection } from '../config/types';
|
||||
import removeInternalFields from '../../utilities/removeInternalFields';
|
||||
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
|
||||
import { Forbidden, NotFound } from '../../errors';
|
||||
import executeAccess from '../../auth/executeAccess';
|
||||
import { Document, Where } from '../../types';
|
||||
@@ -114,9 +114,7 @@ async function findByID(incomingArgs: Arguments): Promise<Document> {
|
||||
// Clone the result - it may have come back memoized
|
||||
result = JSON.parse(JSON.stringify(result));
|
||||
|
||||
result.id = result._id;
|
||||
|
||||
result = removeInternalFields(result);
|
||||
result = sanitizeInternalFields(result);
|
||||
|
||||
// /////////////////////////////////////
|
||||
// beforeRead - Collection
|
||||
|
||||
@@ -4,7 +4,7 @@ import { UploadedFile } from 'express-fileupload';
|
||||
import { Where, Document } from '../../types';
|
||||
import { Collection } from '../config/types';
|
||||
|
||||
import removeInternalFields from '../../utilities/removeInternalFields';
|
||||
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
|
||||
import executeAccess from '../../auth/executeAccess';
|
||||
import { NotFound, Forbidden, APIError, FileUploadError, ValidationError } from '../../errors';
|
||||
import isImage from '../../uploads/isImage';
|
||||
@@ -261,9 +261,9 @@ async function update(incomingArgs: Arguments): Promise<Document> {
|
||||
}
|
||||
|
||||
result = result.toJSON({ virtuals: true });
|
||||
result = removeInternalFields(result);
|
||||
result = JSON.stringify(result);
|
||||
result = JSON.parse(result);
|
||||
result = sanitizeInternalFields(result);
|
||||
|
||||
// /////////////////////////////////////
|
||||
// afterRead - Fields
|
||||
|
||||
10
src/errors/UnathorizedError.ts
Normal file
10
src/errors/UnathorizedError.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import httpStatus from 'http-status';
|
||||
import APIError from './APIError';
|
||||
|
||||
class UnauthorizedError extends APIError {
|
||||
constructor() {
|
||||
super('Unauthorized, you must be logged in to make this request.', httpStatus.UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
export default UnauthorizedError;
|
||||
15
src/fields/baseFields/baseBlockFields.ts
Normal file
15
src/fields/baseFields/baseBlockFields.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Field } from '../config/types';
|
||||
import { baseIDField } from './baseIDField';
|
||||
|
||||
export const baseBlockFields: Field[] = [
|
||||
baseIDField,
|
||||
{
|
||||
name: 'blockName',
|
||||
label: 'Block Name',
|
||||
type: 'text',
|
||||
required: false,
|
||||
admin: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
18
src/fields/baseFields/baseIDField.ts
Normal file
18
src/fields/baseFields/baseIDField.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import ObjectID from 'bson-objectid';
|
||||
import { Field, FieldHook } from '../config/types';
|
||||
|
||||
const generateID: FieldHook = ({ value }) => (value || new ObjectID().toHexString());
|
||||
|
||||
export const baseIDField: Field = {
|
||||
name: 'id',
|
||||
label: 'ID',
|
||||
type: 'text',
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
generateID,
|
||||
],
|
||||
},
|
||||
admin: {
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { formatLabels, toWords } from '../../utilities/formatLabels';
|
||||
import { MissingFieldType, InvalidFieldRelationship } from '../../errors';
|
||||
import { baseBlockFields } from '../baseFields/baseBlockFields';
|
||||
import validations from '../validations';
|
||||
import { baseIDField } from '../baseFields/baseIDField';
|
||||
|
||||
const sanitizeFields = (fields, validRelationships: string[]) => {
|
||||
if (!fields) return [];
|
||||
@@ -24,6 +26,14 @@ const sanitizeFields = (fields, validRelationships: string[]) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (field.type === 'blocks') {
|
||||
field.blocks = field.blocks.map((block) => ({ ...block, fields: block.fields.concat(baseBlockFields) }));
|
||||
}
|
||||
|
||||
if (field.type === 'array') {
|
||||
field.fields.push(baseIDField);
|
||||
}
|
||||
|
||||
if ((field.type === 'blocks' || field.type === 'array') && field.label !== false) {
|
||||
field.labels = field.labels || formatLabels(field.name);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import executeAccess from '../../auth/executeAccess';
|
||||
import removeInternalFields from '../../utilities/removeInternalFields';
|
||||
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
|
||||
|
||||
async function findOne(args) {
|
||||
const { globals: { Model } } = this;
|
||||
@@ -31,9 +31,9 @@ async function findOne(args) {
|
||||
delete doc._id;
|
||||
}
|
||||
|
||||
doc = removeInternalFields(doc);
|
||||
doc = JSON.stringify(doc);
|
||||
doc = JSON.parse(doc);
|
||||
doc = sanitizeInternalFields(doc);
|
||||
|
||||
// /////////////////////////////////////
|
||||
// 3. Execute before collection hook
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import executeAccess from '../../auth/executeAccess';
|
||||
import removeInternalFields from '../../utilities/removeInternalFields';
|
||||
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
|
||||
|
||||
async function update(args) {
|
||||
const { globals: { Model } } = this;
|
||||
@@ -122,9 +122,9 @@ async function update(args) {
|
||||
}
|
||||
|
||||
global = global.toJSON({ virtuals: true });
|
||||
global = removeInternalFields(global);
|
||||
global = JSON.stringify(global);
|
||||
global = JSON.parse(global);
|
||||
global = sanitizeInternalFields(global);
|
||||
|
||||
// /////////////////////////////////////
|
||||
// afterRead - Fields
|
||||
|
||||
@@ -11,6 +11,7 @@ import buildLocaleInputType from './schema/buildLocaleInputType';
|
||||
import buildFallbackLocaleInputType from './schema/buildFallbackLocaleInputType';
|
||||
import initCollections from '../collections/graphql/init';
|
||||
import initGlobals from '../globals/graphql/init';
|
||||
import initPreferences from '../preferences/graphql/init';
|
||||
import { GraphQLResolvers } from './bindResolvers';
|
||||
import buildWhereInputType from './schema/buildWhereInputType';
|
||||
import { Config } from '../config/types';
|
||||
@@ -49,6 +50,8 @@ class InitializeGraphQL {
|
||||
|
||||
initGlobals: typeof initGlobals;
|
||||
|
||||
initPreferences: typeof initPreferences;
|
||||
|
||||
schema: GraphQL.GraphQLSchema;
|
||||
|
||||
extensions: (info: any) => Promise<any>;
|
||||
@@ -89,9 +92,11 @@ class InitializeGraphQL {
|
||||
this.buildPoliciesType = buildPoliciesType.bind(this);
|
||||
this.initCollections = initCollections.bind(this);
|
||||
this.initGlobals = initGlobals.bind(this);
|
||||
this.initPreferences = initPreferences.bind(this);
|
||||
|
||||
this.initCollections();
|
||||
this.initGlobals();
|
||||
this.initPreferences();
|
||||
|
||||
this.Query.fields.Access = {
|
||||
type: this.buildPoliciesType(),
|
||||
|
||||
@@ -15,10 +15,6 @@ function buildBlockType(block: Block): void {
|
||||
formattedBlockName,
|
||||
[
|
||||
...block.fields,
|
||||
{
|
||||
name: 'blockName',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'blockType',
|
||||
type: 'text',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express, { Express, Router } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { Document, Model } from 'mongoose';
|
||||
import {
|
||||
Config,
|
||||
EmailOptions,
|
||||
@@ -18,6 +19,7 @@ import expressMiddleware from './express/middleware';
|
||||
import initAdmin from './express/admin';
|
||||
import initAuth from './auth/init';
|
||||
import initCollections from './collections/init';
|
||||
import initPreferences from './preferences/init';
|
||||
import initGlobals from './globals/init';
|
||||
import { Globals } from './globals/config/types';
|
||||
import initGraphQLPlayground from './graphql/initPlayground';
|
||||
@@ -40,6 +42,7 @@ import { Options as FindOptions } from './collections/operations/local/find';
|
||||
import { Options as FindByIDOptions } from './collections/operations/local/findByID';
|
||||
import { Options as UpdateOptions } from './collections/operations/local/update';
|
||||
import { Options as DeleteOptions } from './collections/operations/local/delete';
|
||||
import { Preference } from './preferences/types';
|
||||
|
||||
require('isomorphic-fetch');
|
||||
|
||||
@@ -55,6 +58,8 @@ export class Payload {
|
||||
resolvers: GraphQLResolvers
|
||||
};
|
||||
|
||||
preferences: { Model: Model<Document<Preference>> };
|
||||
|
||||
globals: Globals;
|
||||
|
||||
logger = Logger();
|
||||
@@ -140,6 +145,7 @@ export class Payload {
|
||||
// Initialize collections & globals
|
||||
initCollections(this);
|
||||
initGlobals(this);
|
||||
initPreferences(this);
|
||||
|
||||
// Connect to database
|
||||
connectMongoose(this.mongoURL, options.mongoOptions);
|
||||
|
||||
@@ -20,6 +20,10 @@ import deleteHandler from '../collections/operations/delete';
|
||||
import findOne from '../globals/operations/findOne';
|
||||
import globalUpdate from '../globals/operations/update';
|
||||
|
||||
import preferenceUpdate from '../preferences/operations/update';
|
||||
import preferenceFindOne from '../preferences/operations/findOne';
|
||||
import preferenceDelete from '../preferences/operations/delete';
|
||||
|
||||
function bindOperations(ctx: Payload): void {
|
||||
ctx.operations = {
|
||||
collections: {
|
||||
@@ -46,6 +50,11 @@ function bindOperations(ctx: Payload): void {
|
||||
findOne: findOne.bind(ctx),
|
||||
update: globalUpdate.bind(ctx),
|
||||
},
|
||||
preferences: {
|
||||
update: preferenceUpdate.bind(ctx),
|
||||
findOne: preferenceFindOne.bind(ctx),
|
||||
delete: preferenceDelete.bind(ctx),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ import deleteHandler from '../collections/requestHandlers/delete';
|
||||
import findOne from '../globals/requestHandlers/findOne';
|
||||
import globalUpdate from '../globals/requestHandlers/update';
|
||||
import { Payload } from '../index';
|
||||
import preferenceUpdate from '../preferences/requestHandlers/update';
|
||||
import preferenceFindOne from '../preferences/requestHandlers/findOne';
|
||||
import preferenceDelete from '../preferences/requestHandlers/delete';
|
||||
|
||||
export type RequestHandlers = {
|
||||
collections: {
|
||||
@@ -44,7 +47,12 @@ export type RequestHandlers = {
|
||||
globals: {
|
||||
findOne: typeof findOne,
|
||||
update: typeof globalUpdate,
|
||||
}
|
||||
},
|
||||
preferences: {
|
||||
update: typeof preferenceUpdate,
|
||||
findOne: typeof preferenceFindOne,
|
||||
delete: typeof preferenceDelete,
|
||||
},
|
||||
}
|
||||
|
||||
function bindRequestHandlers(ctx: Payload): void {
|
||||
@@ -73,6 +81,11 @@ function bindRequestHandlers(ctx: Payload): void {
|
||||
findOne: findOne.bind(ctx),
|
||||
update: globalUpdate.bind(ctx),
|
||||
},
|
||||
preferences: {
|
||||
update: preferenceUpdate.bind(ctx),
|
||||
findOne: preferenceFindOne.bind(ctx),
|
||||
delete: preferenceDelete.bind(ctx),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const setBlockDiscriminators = (fields: Field[], schema: Schema, config: Config)
|
||||
}
|
||||
});
|
||||
|
||||
const blockSchema = new Schema(blockSchemaFields, { _id: false });
|
||||
const blockSchema = new Schema(blockSchemaFields, { _id: false, id: false });
|
||||
|
||||
if (field.localized) {
|
||||
config.localization.locales.forEach((locale) => {
|
||||
@@ -440,7 +440,7 @@ const fieldToSchemaMap = {
|
||||
};
|
||||
},
|
||||
blocks: (field: BlockField, fields: SchemaDefinition, config: Config): SchemaDefinition => {
|
||||
const baseSchema = [new Schema({ blockName: String }, { discriminatorKey: 'blockType', _id: false, id: false })];
|
||||
const baseSchema = [new Schema({ }, { _id: false, discriminatorKey: 'blockType' })];
|
||||
let schemaToReturn;
|
||||
|
||||
if (field.localized) {
|
||||
|
||||
64
src/preferences/graphql/init.ts
Normal file
64
src/preferences/graphql/init.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { GraphQLJSON } from 'graphql-type-json';
|
||||
import {
|
||||
GraphQLNonNull,
|
||||
GraphQLObjectType,
|
||||
GraphQLString,
|
||||
} from 'graphql';
|
||||
import { DateTimeResolver } from 'graphql-scalars';
|
||||
|
||||
function registerPreferences(): void {
|
||||
const {
|
||||
findOne, update, delete: deleteOperation,
|
||||
} = this.operations.preferences;
|
||||
|
||||
|
||||
const valueType = GraphQLJSON;
|
||||
|
||||
const preferenceType = new GraphQLObjectType({
|
||||
name: 'Preference',
|
||||
fields: {
|
||||
key: {
|
||||
type: GraphQLNonNull(GraphQLString),
|
||||
},
|
||||
value: { type: valueType },
|
||||
createdAt: { type: new GraphQLNonNull(DateTimeResolver) },
|
||||
updatedAt: { type: new GraphQLNonNull(DateTimeResolver) },
|
||||
},
|
||||
});
|
||||
|
||||
this.Query.fields.Preference = {
|
||||
type: preferenceType,
|
||||
args: {
|
||||
key: { type: GraphQLString },
|
||||
},
|
||||
resolve: (_, { key }, context) => {
|
||||
const { user } = context.req;
|
||||
return findOne({ key, user, req: context.req });
|
||||
},
|
||||
};
|
||||
|
||||
this.Mutation.fields.updatePreference = {
|
||||
type: preferenceType,
|
||||
args: {
|
||||
key: { type: new GraphQLNonNull(GraphQLString) },
|
||||
value: { type: valueType },
|
||||
},
|
||||
resolve: (_, { key, value }, context) => {
|
||||
const { user } = context.req;
|
||||
return update({ key, user, req: context.req, value });
|
||||
},
|
||||
};
|
||||
|
||||
this.Mutation.fields.deletePreference = {
|
||||
type: preferenceType,
|
||||
args: {
|
||||
key: { type: new GraphQLNonNull(GraphQLString) },
|
||||
},
|
||||
resolve: (_, { key }, context) => {
|
||||
const { user } = context.req;
|
||||
return deleteOperation({ key, user, req: context.req });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default registerPreferences;
|
||||
135
src/preferences/graphql/resolvers.spec.js
Normal file
135
src/preferences/graphql/resolvers.spec.js
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
import { request, GraphQLClient } from 'graphql-request';
|
||||
import getConfig from '../../config/load';
|
||||
import { email, password } from '../../../tests/api/credentials';
|
||||
|
||||
require('isomorphic-fetch');
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
const url = `${config.serverURL}${config.routes.api}${config.routes.graphQL}`;
|
||||
|
||||
let client = null;
|
||||
let token = null;
|
||||
|
||||
describe('GrahpQL Preferences', () => {
|
||||
beforeAll(async (done) => {
|
||||
const query = `
|
||||
mutation {
|
||||
loginAdmin(
|
||||
email: "${email}",
|
||||
password: "${password}"
|
||||
) {
|
||||
token
|
||||
}
|
||||
}`;
|
||||
|
||||
const response = await request(url, query);
|
||||
|
||||
token = response.loginAdmin.token;
|
||||
|
||||
client = new GraphQLClient(url, { headers: { Authorization: `JWT ${token}` } });
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
describe('Update', () => {
|
||||
it('should allow a preference to be saved', async () => {
|
||||
const key = 'preference-key';
|
||||
const value = 'preference-value';
|
||||
|
||||
// language=graphQL
|
||||
const query = `mutation {
|
||||
updatePreference(key: "${key}", value: "${value}") {
|
||||
key
|
||||
value
|
||||
}
|
||||
}`;
|
||||
|
||||
const response = await client.request(query);
|
||||
|
||||
const data = response.updatePreference;
|
||||
|
||||
expect(data.key).toBe(key);
|
||||
expect(data.value).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Read', () => {
|
||||
it('should be able to read user preference', async () => {
|
||||
const key = 'preference-key';
|
||||
const value = 'preference-value';
|
||||
|
||||
// language=graphQL
|
||||
const query = `mutation {
|
||||
updatePreference(key: "${key}", value: "${value}") {
|
||||
key
|
||||
value
|
||||
}
|
||||
}`;
|
||||
|
||||
const response = await client.request(query);
|
||||
|
||||
const { key: responseKey, value: responseValue } = response.updatePreference;
|
||||
// language=graphQL
|
||||
const readQuery = `query {
|
||||
Preference(key: "${responseKey}") {
|
||||
key
|
||||
value
|
||||
}
|
||||
}`;
|
||||
const readResponse = await client.request(readQuery);
|
||||
|
||||
expect(responseKey).toStrictEqual(key);
|
||||
expect(readResponse.Preference.key).toStrictEqual(key);
|
||||
expect(responseValue).toStrictEqual(value);
|
||||
expect(readResponse.Preference.value).toStrictEqual(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Delete', () => {
|
||||
it('should be able to delete a preference', async () => {
|
||||
const key = 'gql-delete';
|
||||
const value = 'description';
|
||||
|
||||
// language=graphQL
|
||||
const query = `mutation {
|
||||
updatePreference(key: "${key}" value: "${value}") {
|
||||
key
|
||||
value
|
||||
}
|
||||
}`;
|
||||
|
||||
const response = await client.request(query);
|
||||
|
||||
const { key: responseKey } = response.updatePreference;
|
||||
// language=graphQL
|
||||
const deleteMutation = `mutation {
|
||||
deletePreference(key: "${key}") {
|
||||
key
|
||||
value
|
||||
}
|
||||
}`;
|
||||
const deleteResponse = await client.request(deleteMutation);
|
||||
const deleteKey = deleteResponse.deletePreference.key;
|
||||
|
||||
expect(responseKey).toStrictEqual(key);
|
||||
expect(deleteKey).toStrictEqual(key);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null when query key is not found', async () => {
|
||||
const key = 'bad-key';
|
||||
const readQuery = `query {
|
||||
Preference(key: "${key}") {
|
||||
key
|
||||
value
|
||||
}
|
||||
}`;
|
||||
const response = await client.request(readQuery);
|
||||
|
||||
expect(response.Preference).toBeNull();
|
||||
});
|
||||
});
|
||||
21
src/preferences/init.ts
Normal file
21
src/preferences/init.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import express from 'express';
|
||||
|
||||
import { Payload } from '../index';
|
||||
import Model from './model';
|
||||
|
||||
export default function initPreferences(ctx: Payload): void {
|
||||
const { findOne, update, delete: deleteHandler } = ctx.requestHandlers.preferences;
|
||||
|
||||
ctx.preferences = { Model };
|
||||
|
||||
if (!ctx.local) {
|
||||
const router = express.Router();
|
||||
router
|
||||
.route('/_preferences/:key')
|
||||
.get(findOne)
|
||||
.post(update)
|
||||
.delete(deleteHandler);
|
||||
|
||||
ctx.router.use(router);
|
||||
}
|
||||
}
|
||||
14
src/preferences/model.ts
Normal file
14
src/preferences/model.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import mongoose, { Schema } from 'mongoose';
|
||||
|
||||
const Model = mongoose.model('_preferences', new Schema({
|
||||
user: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'userCollection',
|
||||
},
|
||||
userCollection: String,
|
||||
key: String,
|
||||
value: Schema.Types.Mixed,
|
||||
}, { timestamps: true })
|
||||
.index({ user: 1, key: 1, userCollection: 1 }, { unique: true }));
|
||||
|
||||
export default Model;
|
||||
40
src/preferences/operations/delete.ts
Normal file
40
src/preferences/operations/delete.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { DeleteWriteOpResultObject } from 'mongodb';
|
||||
import executeAccess from '../../auth/executeAccess';
|
||||
import defaultAccess from '../../auth/defaultAccess';
|
||||
import UnauthorizedError from '../../errors/UnathorizedError';
|
||||
import { NotFound } from '../../errors';
|
||||
import { PreferenceRequest } from '../types';
|
||||
|
||||
async function handleDelete(args: PreferenceRequest): Promise<DeleteWriteOpResultObject> {
|
||||
const { preferences: { Model } } = this;
|
||||
const {
|
||||
overrideAccess,
|
||||
req,
|
||||
user,
|
||||
key,
|
||||
} = args;
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
if (!overrideAccess) {
|
||||
await executeAccess({ req }, defaultAccess);
|
||||
}
|
||||
|
||||
const filter = {
|
||||
key,
|
||||
user: user.id,
|
||||
userCollection: user.collection,
|
||||
};
|
||||
|
||||
const result = await Model.findOneAndDelete(filter);
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default handleDelete;
|
||||
36
src/preferences/operations/findOne.ts
Normal file
36
src/preferences/operations/findOne.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Preference, PreferenceRequest } from '../types';
|
||||
import executeAccess from '../../auth/executeAccess';
|
||||
import defaultAccess from '../../auth/defaultAccess';
|
||||
import UnauthorizedError from '../../errors/UnathorizedError';
|
||||
|
||||
async function findOne(args: PreferenceRequest): Promise<Preference> {
|
||||
const { preferences: { Model } } = this;
|
||||
const {
|
||||
overrideAccess,
|
||||
req,
|
||||
user,
|
||||
key,
|
||||
} = args;
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
if (!overrideAccess) {
|
||||
await executeAccess({ req }, defaultAccess);
|
||||
}
|
||||
|
||||
const filter = {
|
||||
key,
|
||||
user: user.id,
|
||||
userCollection: user.collection,
|
||||
};
|
||||
|
||||
const doc = await Model.findOne(filter);
|
||||
|
||||
if (!doc) return null;
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
export default findOne;
|
||||
31
src/preferences/operations/update.ts
Normal file
31
src/preferences/operations/update.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Preference, PreferenceUpdateRequest } from '../types';
|
||||
import defaultAccess from '../../auth/defaultAccess';
|
||||
import executeAccess from '../../auth/executeAccess';
|
||||
import UnauthorizedError from '../../errors/UnathorizedError';
|
||||
|
||||
async function update(args: PreferenceUpdateRequest) {
|
||||
const { preferences: { Model } } = this;
|
||||
const {
|
||||
overrideAccess,
|
||||
user,
|
||||
req,
|
||||
key,
|
||||
value,
|
||||
} = args;
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
if (!overrideAccess) {
|
||||
await executeAccess({ req }, defaultAccess);
|
||||
}
|
||||
|
||||
const filter = { user: user.id, key, userCollection: user.collection };
|
||||
const preference: Preference = { ...filter, value };
|
||||
await Model.updateOne(filter, { ...preference }, { upsert: true });
|
||||
|
||||
return preference;
|
||||
}
|
||||
|
||||
export default update;
|
||||
20
src/preferences/requestHandlers/delete.ts
Normal file
20
src/preferences/requestHandlers/delete.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextFunction, Response } from 'express';
|
||||
import httpStatus from 'http-status';
|
||||
import { PayloadRequest } from '../../express/types';
|
||||
import formatSuccessResponse from '../../express/responses/formatSuccess';
|
||||
|
||||
export default async function deleteHandler(req: PayloadRequest, res: Response, next: NextFunction): Promise<Response<any> | void> {
|
||||
try {
|
||||
await this.operations.preferences.delete({
|
||||
req,
|
||||
user: req.user,
|
||||
key: req.params.key,
|
||||
});
|
||||
|
||||
return res.status(httpStatus.OK).json({
|
||||
...formatSuccessResponse('Deleted successfully.', 'message'),
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
18
src/preferences/requestHandlers/findOne.ts
Normal file
18
src/preferences/requestHandlers/findOne.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextFunction, Response } from 'express';
|
||||
import httpStatus from 'http-status';
|
||||
import { PayloadRequest } from '../../express/types';
|
||||
import { Preference } from '../types';
|
||||
|
||||
export default async function findOne(req: PayloadRequest, res: Response, next: NextFunction): Promise<Response<Preference> | void> {
|
||||
try {
|
||||
const result = await this.operations.preferences.findOne({
|
||||
req,
|
||||
user: req.user,
|
||||
key: req.params.key,
|
||||
});
|
||||
|
||||
return res.status(httpStatus.OK).json(result || { message: 'No Preference Found', value: null });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
25
src/preferences/requestHandlers/update.ts
Normal file
25
src/preferences/requestHandlers/update.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextFunction, Response } from 'express';
|
||||
import httpStatus from 'http-status';
|
||||
import { PayloadRequest } from '../../express/types';
|
||||
import formatSuccessResponse from '../../express/responses/formatSuccess';
|
||||
|
||||
export type UpdatePreferenceResult = Promise<Response<Document> | void>;
|
||||
export type UpdatePreferenceResponse = (req: PayloadRequest, res: Response, next: NextFunction) => UpdatePreferenceResult;
|
||||
|
||||
export default async function update(req: PayloadRequest, res: Response, next: NextFunction): Promise<Response<any> | void> {
|
||||
try {
|
||||
const doc = await this.operations.preferences.update({
|
||||
req,
|
||||
user: req.user,
|
||||
key: req.params.key,
|
||||
value: req.body.value || req.body,
|
||||
});
|
||||
|
||||
return res.status(httpStatus.OK).json({
|
||||
...formatSuccessResponse('Updated successfully.', 'message'),
|
||||
doc,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
32
src/preferences/types.ts
Normal file
32
src/preferences/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Request } from 'express';
|
||||
import { User } from '../auth';
|
||||
|
||||
export type Preference = {
|
||||
user: string;
|
||||
userCollection: string;
|
||||
key: string;
|
||||
value: { [key: string]: unknown } | unknown;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
};
|
||||
|
||||
export type PreferenceRequest = {
|
||||
overrideAccess?: boolean;
|
||||
req: Request;
|
||||
user: User;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type PreferenceUpdateRequest = PreferenceRequest & {value: undefined};
|
||||
|
||||
export type CollapsedPreferences = string[]
|
||||
|
||||
export type FieldsPreferences = {
|
||||
[key: string]: {
|
||||
collapsed: CollapsedPreferences
|
||||
}
|
||||
}
|
||||
|
||||
export type DocumentPreferences = {
|
||||
fields: FieldsPreferences
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
const internalFields = ['_id', '__v', 'salt', 'hash'];
|
||||
|
||||
const removeInternalFields = (incomingDoc) => Object.entries(incomingDoc).reduce((newDoc, [key, val]) => {
|
||||
if (internalFields.indexOf(key) > -1) {
|
||||
return newDoc;
|
||||
}
|
||||
|
||||
return {
|
||||
...newDoc,
|
||||
[key]: val,
|
||||
};
|
||||
}, {});
|
||||
|
||||
export default removeInternalFields;
|
||||
21
src/utilities/sanitizeInternalFields.ts
Normal file
21
src/utilities/sanitizeInternalFields.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
const internalFields = ['__v', 'salt', 'hash'];
|
||||
|
||||
const sanitizeInternalFields = (incomingDoc) => Object.entries(incomingDoc).reduce((newDoc, [key, val]) => {
|
||||
if (key === '_id') {
|
||||
return {
|
||||
...newDoc,
|
||||
id: val,
|
||||
};
|
||||
}
|
||||
|
||||
if (internalFields.indexOf(key) > -1) {
|
||||
return newDoc;
|
||||
}
|
||||
|
||||
return {
|
||||
...newDoc,
|
||||
[key]: val,
|
||||
};
|
||||
}, {});
|
||||
|
||||
export default sanitizeInternalFields;
|
||||
Reference in New Issue
Block a user