feat: add drag-and-drop to relations (#19788)

* feat: add drag-and-drop to relations

Co-Authored-By: Marc Roig <20578351+Marc-Roig@users.noreply.github.com>

* chore: spelling mistakes

* chore: fix mainField accessing

* chore: remove comment code

Co-Authored-By: Marc Roig <marc12info@gmail.com>

---------

Co-authored-by: Marc Roig <20578351+Marc-Roig@users.noreply.github.com>
Co-authored-by: Marc Roig <marc12info@gmail.com>
This commit is contained in:
Josh 2024-03-15 15:16:35 +00:00 committed by GitHub
parent f62c536bfa
commit 98ecaa5093
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 419 additions and 152 deletions

View File

@ -233,14 +233,18 @@ describe('Find Relations', () => {
// Create draft products
const [skate, chair, candle, table, porte, fenetre] = await Promise.all([
strapi.documents(productUid).create({ data: { name: 'Skate' } }),
strapi.documents(productUid).create({ data: { name: 'Chair' } }),
strapi.documents(productUid).create({ data: { name: 'Candle' } }),
strapi.documents(productUid).create({ data: { name: 'Table' } }),
strapi.documents(productUid).create({ data: { name: 'Skate' }, status: 'published' }),
strapi.documents(productUid).create({ data: { name: 'Chair' }, status: 'published' }),
strapi.documents(productUid).create({ data: { name: 'Candle' }, status: 'published' }),
strapi.documents(productUid).create({ data: { name: 'Table' }, status: 'published' }),
// We create products in French in order to test that we can cant find
// aviailable relations in a different locale
strapi.documents(productUid).create({ data: { name: 'Porte' }, locale: extraLocale }),
strapi.documents(productUid).create({ data: { name: 'Fenetre' }, locale: extraLocale }),
// available relations in a different locale
strapi
.documents(productUid)
.create({ data: { name: 'Porte' }, locale: extraLocale, status: 'published' }),
strapi
.documents(productUid)
.create({ data: { name: 'Fenetre' }, locale: extraLocale, status: 'published' }),
]);
data[productUid].draft.push(skate, chair, candle, table, porte, fenetre);
@ -337,6 +341,11 @@ describe('Find Relations', () => {
id: data[shopUid].draft[0].myCompo.id,
idEmptyShop: data[shopUid].draft[1].myCompo.id,
},
publishedComponent: {
modelUID: compoUid,
id: data[shopUid].published[0].myCompo.id,
idEmptyShop: undefined,
},
entity: {
// If the source of the relation is a content type, we use the documentId
modelUID: shopUid,
@ -825,7 +834,9 @@ describe('Find Relations', () => {
});
test('Can query by status for existing relations', async () => {
const { id, modelUID } = isComponent ? data.testData.component : data.testData.entity;
const { id, modelUID } = isComponent
? data.testData.publishedComponent
: data.testData.entity;
const res = await rq({
method: 'GET',

View File

@ -24,6 +24,7 @@ const productModel = {
type: 'string',
},
},
draftAndPublish: false,
displayName: 'Product',
singularName: 'product',
pluralName: 'products',
@ -48,6 +49,7 @@ const shopModel = {
targetAttribute: 'shops',
},
},
draftAndPublish: false,
displayName: 'Shop',
singularName: 'shop',
pluralName: 'shops',
@ -58,6 +60,10 @@ const shops = [
name: 'market',
locale: 'en',
},
{
name: 'mercato',
locale: 'it',
},
];
const products = ({ shop }) => {
@ -119,7 +125,6 @@ describe('i18n - Find available relations', () => {
const expectedObj = {
...pick(['id', 'name', 'publishedAt', 'documentId', 'locale', 'updatedAt'], data.products[1]),
status: 'published',
};
expect(res.body.results).toHaveLength(1);
expect(res.body.results[0]).toStrictEqual(expectedObj);
@ -134,7 +139,6 @@ describe('i18n - Find available relations', () => {
const expectedObj = {
...pick(['id', 'name', 'publishedAt', 'documentId', 'locale', 'updatedAt'], data.products[0]),
status: 'published',
};
expect(res.body.results).toHaveLength(1);
expect(res.body.results[0]).toStrictEqual(expectedObj);

View File

@ -24,6 +24,7 @@ const productModel = {
type: 'string',
},
},
draftAndPublish: false,
displayName: 'Product',
singularName: 'product',
pluralName: 'products',
@ -48,6 +49,7 @@ const shopModel = {
targetAttribute: 'shops',
},
},
draftAndPublish: false,
displayName: 'Shop',
singularName: 'shop',
pluralName: 'shops',
@ -130,7 +132,6 @@ describe('i18n - Find existing relations', () => {
locale,
publishedAt,
updatedAt,
status: 'published',
};
expect(res.body.results).toHaveLength(1);
@ -155,7 +156,6 @@ describe('i18n - Find existing relations', () => {
locale,
publishedAt,
updatedAt,
status: 'published',
};
expect(res.body.results).toHaveLength(1);

View File

@ -16,7 +16,7 @@ module.exports = {
* This gives you an opportunity to set up your data model,
* run jobs, or perform some special logic.
*/
bootstrap({ strapi }) {},
async bootstrap({ strapi }) {},
/**
* An asynchronous destroy function that runs before

View File

@ -560,7 +560,6 @@ const reducer = <TFormValues extends FormValues = FormValues>(
*/
const currentField = [...(getIn(state.values, field, []) as Array<any>)];
const currentRow = currentField[fromIndex];
const newIndex = action.payload.toIndex;
const startKey =
fromIndex > toIndex
@ -573,7 +572,7 @@ const reducer = <TFormValues extends FormValues = FormValues>(
const [newKey] = generateNKeysBetween(startKey, endKey, 1);
currentField.splice(fromIndex, 1);
currentField.splice(newIndex, 0, { ...currentRow, __temp_key__: newKey });
currentField.splice(toIndex, 0, { ...currentRow, __temp_key__: newKey });
draft.values = setIn(state.values, field, currentField);

View File

@ -1,6 +1,7 @@
import * as React from 'react';
import { useAPIErrorHandler, useNotification } from '@strapi/helper-plugin';
import { Helmet } from 'react-helmet';
import { useParams } from 'react-router-dom';
import { Page } from '../../components/PageHelpers';
@ -199,12 +200,15 @@ const ComponentConfigurationPage = () => {
}
return (
<ConfigurationForm
onSubmit={handleSubmit}
attributes={schema.attributes}
fieldSizes={fieldSizes}
layout={editLayout}
/>
<>
<Helmet title={`Configure ${editLayout.settings.displayName} Edit View | Strapi`} />
<ConfigurationForm
onSubmit={handleSubmit}
attributes={schema.attributes}
fieldSizes={fieldSizes}
layout={editLayout}
/>
</>
);
};

View File

@ -1,6 +1,7 @@
import * as React from 'react';
import { useAPIErrorHandler, useNotification, useTracking } from '@strapi/helper-plugin';
import { Helmet } from 'react-helmet';
import { Page } from '../../components/PageHelpers';
import { useTypedSelector } from '../../core/store/hooks';
@ -138,12 +139,15 @@ const EditConfigurationPage = () => {
}
return (
<ConfigurationForm
onSubmit={handleSubmit}
attributes={schema.attributes}
fieldSizes={fieldSizes}
layout={edit}
/>
<>
<Helmet title={`Configure ${edit.settings.displayName} Edit View | Strapi`} />
<ConfigurationForm
onSubmit={handleSubmit}
attributes={schema.attributes}
fieldSizes={fieldSizes}
layout={edit}
/>
</>
);
};

View File

@ -16,7 +16,7 @@ import {
import { Link } from '@strapi/design-system/v2';
import { useFocusInputField, useNotification, useQueryParams } from '@strapi/helper-plugin';
import { Cross, Drag, Refresh } from '@strapi/icons';
import { Contracts } from '@strapi/plugin-content-manager/_internal/shared';
import { generateNKeysBetween } from 'fractional-indexing';
import pipe from 'lodash/fp/pipe';
import { getEmptyImage } from 'react-dnd-html5-backend';
import { useIntl } from 'react-intl';
@ -24,17 +24,21 @@ import { NavLink } from 'react-router-dom';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import styled from 'styled-components';
import { RelationResult } from '../../../../../../../../content-manager/dist/shared/contracts/relations';
import { type InputProps, useField, useForm } from '../../../../../components/Form';
import { COLLECTION_TYPES } from '../../../../constants/collections';
import { ItemTypes } from '../../../../constants/dragAndDrop';
import { useDoc } from '../../../../hooks/useDocument';
import { type EditFieldLayout } from '../../../../hooks/useDocumentLayout';
import {
DROP_SENSITIVITY,
UseDragAndDropOptions,
useDragAndDrop,
} from '../../../../hooks/useDragAndDrop';
import { useGetRelationsQuery, useLazySearchRelationsQuery } from '../../../../services/relations';
import {
useGetRelationsQuery,
useLazySearchRelationsQuery,
RelationResult,
} from '../../../../services/relations';
import { buildValidParams } from '../../../../utils/api';
import { getRelationLabel } from '../../../../utils/relations';
import { getTranslation } from '../../../../utils/translations';
@ -42,7 +46,6 @@ import { DocumentStatus } from '../DocumentStatus';
import { useComponent } from './ComponentContext';
import type { EditFieldLayout } from '../../../../hooks/useDocumentLayout';
import type { Attribute } from '@strapi/types';
/* -------------------------------------------------------------------------------------------------
@ -51,10 +54,18 @@ import type { Attribute } from '@strapi/types';
const RELATIONS_TO_DISPLAY = 5;
const ONE_WAY_RELATIONS = ['oneWay', 'oneToOne', 'manyToOne', 'oneToManyMorph', 'oneToOneMorph'];
interface Relation extends Contracts.Relations.RelationResult {
type RelationPosition =
| (Pick<RelationResult, 'status' | 'locale'> & {
before: string;
end?: never;
})
| { end: boolean; before?: never; status?: never; locale?: never };
interface Relation extends Pick<RelationResult, 'documentId' | 'id' | 'locale' | 'status'> {
href: string;
label: string;
[key: string]: any;
position?: RelationPosition;
__temp_key__: string;
}
interface RelationsFieldProps
@ -62,8 +73,8 @@ interface RelationsFieldProps
Pick<InputProps, 'hint'> {}
interface RelationsFormValue {
connect?: Contracts.Relations.RelationResult[];
disconnect?: Contracts.Relations.RelationResult[];
connect?: Relation[];
disconnect?: Pick<RelationResult, 'documentId'>[];
}
/**
@ -140,17 +151,29 @@ const RelationsField = React.forwardRef<HTMLDivElement, RelationsFieldProps>(
setCurrentPage((prev) => prev + 1);
};
const field = useField<RelationsFormValue>(props.name);
const field = useField(props.name);
const isFetchingMoreRelations = isLoading || isFetching;
const realServerRelationsCount =
'pagination' in data && data.pagination ? data.pagination.total : 0;
const relationsConnected = field.value?.connect?.length ?? 0;
/**
* Items that are already connected, but reordered would be in
* this list, so to get an accurate figure, we remove them.
*/
const relationsConnected =
(field.value?.connect ?? []).filter(
(rel: Relation) => data.results.findIndex((relation) => relation.id === rel.id) === -1
).length ?? 0;
const relationsDisconnected = field.value?.disconnect?.length ?? 0;
const relationsCount = realServerRelationsCount + relationsConnected - relationsDisconnected;
/**
* This is it, the source of truth for reordering in conjunction with partial loading & updating
* of relations. Relations on load are given __temp_key__ when fetched, because we don't want to
* create brand new keys everytime the data updates, just keep adding them onto the newly loaded ones.
*/
const relations = React.useMemo(() => {
const ctx = {
field: field.value,
@ -159,9 +182,26 @@ const RelationsField = React.forwardRef<HTMLDivElement, RelationsFieldProps>(
mainField: props.mainField,
};
const transformations = pipe(removeDisconnected(ctx), addLabelAndHref(ctx));
/**
* Tidy up our data.
*/
const transformations = pipe(
removeConnected(ctx),
removeDisconnected(ctx),
addLabelAndHref(ctx)
);
return transformations([...data.results, ...(field.value?.connect ?? [])]);
const transformedRels = transformations([...data.results]);
/**
* THIS IS CRUCIAL. If you don't sort by the __temp_key__ which comes from fractional indexing
* then the list will be in the wrong order.
*/
return [...transformedRels, ...(field.value?.connect ?? [])].sort((a, b) => {
if (a.__temp_key__ < b.__temp_key__) return -1;
if (a.__temp_key__ > b.__temp_key__) return 1;
return 0;
});
}, [
data.results,
field.value,
@ -170,6 +210,29 @@ const RelationsField = React.forwardRef<HTMLDivElement, RelationsFieldProps>(
props.mainField,
]);
const handleConnect: RelationsInputProps['onChange'] = (relation) => {
const [lastItemInList] = relations.slice(-1);
const item = {
...relation,
/**
* If there's a last item, that's the first key we use to generate out next one.
*/
__temp_key__: generateNKeysBetween(lastItemInList?.__temp_key__ ?? null, null, 1)[0],
// Fallback to `id` if there is no `mainField` value, which will overwrite the above `id` property with the exact same data.
[props.mainField?.name ?? 'id']: relation[props.mainField?.name ?? 'id'],
label: getRelationLabel(relation, props.mainField),
// @ts-expect-error targetModel does exist on the attribute, but it's not typed.
href: `../${COLLECTION_TYPES}/${props.attribute.targetModel}/${relation.documentId}`,
};
if (ONE_WAY_RELATIONS.includes(props.attribute.relation)) {
field.onChange(props.name, { connect: [item] });
} else {
field.onChange(`${props.name}.connect`, [...(field.value?.connect ?? []), item]);
}
};
return (
<Flex
ref={ref}
@ -185,6 +248,7 @@ const RelationsField = React.forwardRef<HTMLDivElement, RelationsFieldProps>(
id={id}
label={`${label} ${relationsCount > 0 ? `(${relationsCount})` : ''}`}
model={model}
onChange={handleConnect}
{...props}
/>
{'pagination' in data &&
@ -207,6 +271,7 @@ const RelationsField = React.forwardRef<HTMLDivElement, RelationsFieldProps>(
</StyledFlex>
<RelationsList
data={relations}
serverData={data.results}
disabled={isDisabled}
name={props.name}
isLoading={isFetchingMoreRelations}
@ -236,6 +301,20 @@ interface TransformationContext extends Pick<RelationsFieldProps, 'mainField'> {
href: string;
}
/**
* If it's in the connected array, it can get out of our data array,
* we'll be putting it back in later and sorting it anyway.
*/
const removeConnected =
({ field }: TransformationContext) =>
(relations: RelationResult[]) => {
return relations.filter((relation) => {
const connectedRelations = field?.connect ?? [];
return connectedRelations.findIndex((rel) => rel.documentId === relation.documentId) === -1;
});
};
/**
* @description Removes relations that are in the `disconnect` array of the field
*/
@ -260,6 +339,8 @@ const addLabelAndHref =
relations.map((relation) => {
return {
...relation,
// Fallback to `id` if there is no `mainField` value, which will overwrite the above `id` property with the exact same data.
[mainField?.name ?? 'id']: relation[mainField?.name ?? 'id'],
label: getRelationLabel(relation, mainField),
href: `${href}/${relation.documentId}`,
};
@ -272,6 +353,11 @@ const addLabelAndHref =
interface RelationsInputProps extends Omit<RelationsFieldProps, 'type'> {
id?: string;
model: string;
onChange: (
relation: Pick<RelationResult, 'documentId' | 'id' | 'locale' | 'status'> & {
[key: string]: any;
}
) => void;
}
/**
@ -288,7 +374,7 @@ const RelationsInput = ({
mainField,
placeholder,
required,
attribute,
onChange,
}: RelationsInputProps) => {
const [textValue, setTextValue] = React.useState<string | undefined>('');
const [searchParams, setSearchParams] = React.useState({
@ -301,7 +387,6 @@ const RelationsInput = ({
const { formatMessage } = useIntl();
const fieldRef = useFocusInputField(name);
const field = useField<RelationsFormValue>(name);
const addFieldRow = useForm('RelationInput', (state) => state.addFieldRow);
const [searchForTrigger, { data, isLoading }] = useLazySearchRelationsQuery();
@ -350,8 +435,6 @@ const RelationsInput = ({
const options = data?.results ?? [];
const isSingleRelation = ONE_WAY_RELATIONS.includes(attribute.relation);
const handleChange = (relationId?: string) => {
if (!relationId) {
return;
@ -376,11 +459,14 @@ const RelationsInput = ({
return;
}
if (isSingleRelation) {
field.onChange(name, { connect: [relation] });
} else {
addFieldRow(`${name}.connect`, relation);
}
/**
* You need to give this relation a correct _temp_key_ but
* this component doesn't know about those ones, you can't rely
* on the connect array because that doesn't hold items that haven't
* moved. So use a callback to fill in the gaps when connecting.
*
*/
onChange(relation);
};
const handleLoadMore = () => {
@ -463,16 +549,27 @@ interface RelationsListProps extends Pick<RelationsFieldProps, 'disabled' | 'nam
data: Relation[];
isLoading?: boolean;
relationType: Attribute.Relation['relation'];
/**
* The existing relations connected on the server. We need these to diff against.
*/
serverData: RelationResult[];
}
const RelationsList = ({ data, disabled, name, isLoading, relationType }: RelationsListProps) => {
const RelationsList = ({
data,
serverData,
disabled,
name,
isLoading,
relationType,
}: RelationsListProps) => {
const ariaDescriptionId = React.useId();
const { formatMessage } = useIntl();
const listRef = React.useRef<FixedSizeList>(null);
const outerListRef = React.useRef<HTMLUListElement>(null);
const [overflow, setOverflow] = React.useState<'top' | 'bottom' | 'top-bottom'>();
const [liveText, setLiveText] = React.useState('');
const field = useField<RelationsFormValue>(name);
const field = useField(name);
const removeFieldRow = useForm('RelationsList', (state) => state.removeFieldRow);
const addFieldRow = useForm('RelationsList', (state) => state.addFieldRow);
@ -512,7 +609,7 @@ const RelationsList = ({ data, disabled, name, isLoading, relationType }: Relati
const getItemPos = (index: number) => `${index + 1} of ${data.length}`;
const handleMoveItem: UseDragAndDropOptions['onMoveItem'] = (oldIndex, newIndex) => {
const handleMoveItem: UseDragAndDropOptions['onMoveItem'] = (newIndex, oldIndex) => {
const item = data[oldIndex];
setLiveText(
@ -527,6 +624,59 @@ const RelationsList = ({ data, disabled, name, isLoading, relationType }: Relati
}
)
);
/**
* Splicing mutates the array, so we need to create a new array
*/
const newData = [...data];
const currentRow = data[oldIndex];
const startKey =
oldIndex > newIndex ? newData[newIndex - 1]?.__temp_key__ : newData[newIndex]?.__temp_key__;
const endKey =
oldIndex > newIndex ? newData[newIndex]?.__temp_key__ : newData[newIndex + 1]?.__temp_key__;
/**
* We're moving the relation between two other relations, so
* we need to generate a new key that keeps the order
*/
const [newKey] = generateNKeysBetween(startKey, endKey, 1);
newData.splice(oldIndex, 1);
newData.splice(newIndex, 0, { ...currentRow, __temp_key__: newKey });
/**
* Now we diff against the server to understand what's different so we
* can keep the connect array nice and tidy. It also needs reversing because
* we reverse the relations from the server in the first place.
*/
const connectedRelations = newData
.reduce<Relation[]>((acc, relation, currentIndex, array) => {
const relationOnServer = serverData.find(
(oldRelation) => oldRelation.documentId === relation.documentId
);
const relationInFront = array[currentIndex + 1];
if (!relationOnServer || relationOnServer.__temp_key__ !== relation.__temp_key__) {
const position = relationInFront
? {
before: relationInFront.documentId,
locale: relationInFront.locale,
status: relationInFront.status,
}
: { end: true };
const relationWithPosition: Relation = { ...relation, position };
return [...acc, relationWithPosition];
}
return acc;
}, [])
.toReversed();
field.onChange(`${name}.connect`, connectedRelations);
};
const handleGrabItem: UseDragAndDropOptions['onGrabItem'] = (index) => {
@ -547,7 +697,7 @@ const RelationsList = ({ data, disabled, name, isLoading, relationType }: Relati
};
const handleDropItem: UseDragAndDropOptions['onDropItem'] = (index) => {
const item = data[index];
const { href: _href, label, ...item } = data[index];
setLiveText(
formatMessage(
@ -556,7 +706,7 @@ const RelationsList = ({ data, disabled, name, isLoading, relationType }: Relati
defaultMessage: `{item}, dropped. Final position in list: {position}.`,
},
{
item: item.label ?? item.documentId,
item: label ?? item.documentId,
position: getItemPos(index),
}
)
@ -587,7 +737,8 @@ const RelationsList = ({ data, disabled, name, isLoading, relationType }: Relati
* from the connect array
*/
const indexOfRelationInConnectArray = field.value.connect.findIndex(
(rel) => rel.documentId === relation.documentId
(rel: NonNullable<RelationsFormValue['connect']>[number]) =>
rel.documentId === relation.documentId
);
if (indexOfRelationInConnectArray >= 0) {
@ -736,7 +887,7 @@ const ListItem = ({ data, index, style }: ListItemProps) => {
onDropItem: handleDropItem,
onGrabItem: handleGrabItem,
onCancel: handleCancel,
dropSensitivity: DROP_SENSITIVITY.IMMEDIATE,
dropSensitivity: DROP_SENSITIVITY.REGULAR,
});
const composedRefs = useComposedRefs<HTMLDivElement>(relationRef, dragRef);

View File

@ -2,6 +2,7 @@ import * as React from 'react';
import { ContentLayout, Divider, Flex, Layout, Main } from '@strapi/design-system';
import { useAPIErrorHandler, useNotification, useTracking } from '@strapi/helper-plugin';
import { Helmet } from 'react-helmet';
import { useIntl } from 'react-intl';
import { Navigate } from 'react-router-dom';
@ -118,6 +119,7 @@ const ListConfiguration = () => {
return (
<Layout>
<Helmet title={`Configure ${list.settings.displayName} List View | Strapi`} />
<Main>
<Form initialValues={initialValues} onSubmit={handleSubmit} method="PUT">
<Header

View File

@ -1,13 +1,18 @@
import { Contracts } from '@strapi/plugin-content-manager/_internal/shared';
import { generateNKeysBetween } from 'fractional-indexing';
import { contentManagerApi } from './api';
import type { EntityService } from '@strapi/types';
import type { errors } from '@strapi/utils';
interface RelationResult extends Contracts.Relations.RelationResult {
__temp_key__: string;
}
type GetRelationsResponse =
| {
results: Contracts.Relations.RelationResult[];
results: Array<RelationResult>;
pagination: {
page: NonNullable<EntityService.Params.Pagination.PageNotation['page']>;
pageSize: NonNullable<EntityService.Params.Pagination.PageNotation['pageSize']>;
@ -55,17 +60,17 @@ const relationsApi = contentManagerApi.injectEndpoints({
* Relations will always have unique IDs, so we can therefore assume
* that we only need to push the new items to the cache.
*/
const existingIds = currentCache.results.map((item) => item.id);
const existingIds = currentCache.results.map((item) => item.documentId);
const uniqueNewItems = newItems.results.filter(
(item) => !existingIds.includes(item.id)
(item) => !existingIds.includes(item.documentId)
);
currentCache.results.push(...uniqueNewItems);
currentCache.results.push(...prepareTempKeys(uniqueNewItems, currentCache.results));
currentCache.pagination = newItems.pagination;
} else if (newItems.pagination.page === 1) {
/**
* We're resetting the relations
*/
currentCache.results = newItems.results;
currentCache.results = prepareTempKeys(newItems.results);
currentCache.pagination = newItems.pagination;
}
}
@ -84,7 +89,7 @@ const relationsApi = contentManagerApi.injectEndpoints({
if ('results' in response && response.results) {
return {
...response,
results: response.results,
results: prepareTempKeys(response.results.toReversed()),
};
} else {
return response;
@ -124,9 +129,9 @@ const relationsApi = contentManagerApi.injectEndpoints({
* Relations will always have unique IDs, so we can therefore assume
* that we only need to push the new items to the cache.
*/
const existingIds = currentCache.results.map((item) => item.id);
const existingIds = currentCache.results.map((item) => item.documentId);
const uniqueNewItems = newItems.results.filter(
(item) => !existingIds.includes(item.id)
(item) => !existingIds.includes(item.documentId)
);
currentCache.results.push(...uniqueNewItems);
currentCache.pagination = newItems.pagination;
@ -163,6 +168,26 @@ const relationsApi = contentManagerApi.injectEndpoints({
}),
});
/**
* @internal
* @description Adds a `__temp_key__` to each relation item. This gives us
* a stable identifier regardless of it's ids etc. that we can then use for drag and drop.
*/
const prepareTempKeys = (
relations: Contracts.Relations.RelationResult[],
existingRelations: RelationResult[] = []
) => {
const [firstItem] = existingRelations.slice(0);
const keys = generateNKeysBetween(null, firstItem?.__temp_key__ ?? null, relations.length);
return relations.map((datum, index) => ({
...datum,
__temp_key__: keys[index],
}));
};
const { useGetRelationsQuery, useLazySearchRelationsQuery } = relationsApi;
export { useGetRelationsQuery, useLazySearchRelationsQuery };
export type { RelationResult };

View File

@ -794,7 +794,7 @@
"content-manager.containers.list-settings.modal-form.label": "Edit {fieldName}",
"content-manager.containers.list-settings.modal-form.error": "An error occurred while trying to open the form.",
"content-manager.containers.edit-settings.modal-form.error": "An error occurred while trying to open the form.",
"content-manager.containers.edit-settings.modal-form.label": "Edit {fieldName}",
"content-manager.containers.edit-settings.modal-form.label": "Label",
"content-manager.containers.edit-settings.modal-form.description": "Description",
"content-manager.containers.edit-settings.modal-form.placeholder": "Placeholder",
"content-manager.containers.edit-settings.modal-form.mainField": "Entry title",

View File

@ -1,7 +1,14 @@
import { prop, uniq, flow } from 'lodash/fp';
import { isOperatorOfType, contentTypes } from '@strapi/utils';
import { type Common, type Entity, type Documents } from '@strapi/types';
import { errors } from '@strapi/utils';
import { prop, uniq, uniqBy, concat, flow } from 'lodash/fp';
import {
isOperatorOfType,
contentTypes,
relations,
convertQueryParams,
errors,
} from '@strapi/utils';
import type { Entity, Documents, Common } from '@strapi/types';
import { getService } from '../utils';
import { validateFindAvailable, validateFindExisting } from './validation/relations';
import { isListable } from '../services/utils/configuration/attributes';
@ -51,6 +58,26 @@ const sanitizeMainField = (model: any, mainField: any, userAbility: any) => {
return mainField;
};
const addStatusToRelations = async (uid: Common.UID.ContentType, relations: RelationEntity[]) => {
if (!contentTypes.hasDraftAndPublish(strapi.contentTypes[uid])) {
return relations;
}
const documentMetadata = getService('document-metadata');
const documentsAvailableStatus = await documentMetadata.getManyAvailableStatus(uid, relations);
return relations.map((relation: RelationEntity) => {
const availableStatuses = documentsAvailableStatus.filter(
(availableDocument: RelationEntity) => availableDocument.documentId === relation.documentId
);
return {
...relation,
status: documentMetadata.getStatus(relation, availableStatuses),
};
});
};
export default {
async extractAndValidateRequestInfo(
ctx: any,
@ -78,17 +105,19 @@ export default {
model,
});
const isSourceComponent = sourceSchema.modelType === 'component';
if (!isSourceComponent) {
const isComponent = sourceSchema.modelType === 'component';
if (!isComponent) {
if (permissionChecker.cannot.read(null, targetField)) {
return ctx.forbidden();
}
}
let entryId: string | number | null = null;
if (id) {
const where: Record<string, any> = {};
if (!isSourceComponent) {
if (!isComponent) {
where.documentId = id;
if (status) {
@ -126,14 +155,16 @@ export default {
throw new errors.NotFoundError();
}
if (!isSourceComponent) {
if (!isComponent) {
if (permissionChecker.cannot.read(currentEntity, targetField)) {
throw new errors.ForbiddenError();
}
}
entryId = currentEntity.id;
}
const modelConfig = isSourceComponent
const modelConfig = isComponent
? await getService('components').findConfiguration(sourceSchema)
: await getService('content-types').findConfiguration(sourceSchema);
@ -157,11 +188,13 @@ export default {
.service('content-types')
.isLocalizedContentType(targetSchema);
// TODO: Locale is always present, should we set it regardless?
if (isTargetLocalized) {
fieldsToSelect.push('locale');
}
return {
entryId,
attribute,
fieldsToSelect,
mainField,
@ -173,11 +206,17 @@ export default {
};
},
async find(ctx: any, id: Entity.ID, available: boolean = true) {
/**
* Used to find new relations to add in a relational field.
*
* Component and document relations are dealt a bit differently (they don't have a document_id).
*/
async findAvailable(ctx: any) {
const { id } = ctx.request.query;
const locale = ctx.request?.query?.locale || null;
const status = ctx.request?.query?.status || null;
const status = ctx.request?.query?.status;
const validation = await this.extractAndValidateRequestInfo(ctx, id, locale, status);
await validateFindAvailable(ctx.request.query);
const {
targetField,
@ -190,7 +229,7 @@ export default {
schema: { uid: targetUid },
isLocalized: isTargetLocalized,
},
} = validation;
} = await this.extractAndValidateRequestInfo(ctx, id, locale, status);
const { idsToOmit, idsToInclude, _q, ...query } = ctx.request.query;
@ -220,17 +259,12 @@ export default {
}
if (id) {
// If finding available relations we want to exclude the
// ids of entities that are already related to the source.
// If finding existing we want to include the ids of entities that are
// already related to the source.
// We specify the source by entityId for components and by documentId for
// content types.
// We also optionally filter the target relations by the requested
// status and locale if provided.
/**
* Exclude the relations that are already related to the source
*
* We also optionally filter the target relations by the requested
* status and locale if provided.
*/
const subQuery = strapi.db.queryBuilder(sourceUid);
// The alias refers to the DB table of the target content type model
@ -241,18 +275,17 @@ export default {
[`${alias}.document_id`]: { $notNull: true },
};
const isSourceComponent = sourceModelType === 'component';
if (isSourceComponent) {
// If the source is a component, we need to filter by the component's
// numeric entity id
where.id = id;
} else {
// If the source is a content type, we need to filter by document id
/**
* Content Types -> Specify document id
* Components -> Specify entity id (they don't have a document id)
*/
if (sourceModelType === 'contentType') {
where.document_id = id;
} else {
where.id = id;
}
// If a status or locale is requested from the source, we need to only
// ever find relations that match that status or locale.
// Add the status and locale filters if they are provided
if (status) {
where[`${alias}.published_at`] = status === 'published' ? { $ne: null } : null;
}
@ -260,6 +293,11 @@ export default {
where[`${alias}.locale`] = locale;
}
/**
* UI can provide a list of ids to omit,
* those are the relations user set in the UI but has not persisted.
* We don't want to include them in the available relations.
*/
if ((idsToInclude?.length ?? 0) !== 0) {
where[`${alias}.document_id`].$notIn = idsToInclude;
}
@ -271,15 +309,15 @@ export default {
.getKnexQuery();
addFiltersClause(queryParams, {
// We change the operator based on whether we are looking for available or
// existing relations
id: available ? { $notIn: knexSubQuery } : { $in: knexSubQuery },
id: { $notIn: knexSubQuery },
});
}
/**
* Apply a filter to the mainField based on the search query and filter operator
* searching should be allowed only on mainField for permission reasons
*/
if (_q) {
// Apply a filter to the mainField based on the search query and filter operator
// searching should be allowed only on mainField for permission reasons
const _filter = isOperatorOfType('where', query._filter) ? query._filter : '$containsi';
addFiltersClause(queryParams, { [mainField]: { [_filter]: _q } });
}
@ -291,64 +329,93 @@ export default {
});
}
const res = await strapi.entityService.findPage(
targetUid as Common.UID.ContentType,
queryParams
);
if (status) {
// The result will contain all relations in the requested status, and we don't need to find
// the latest status for each.
ctx.body = {
...res,
results: res.results.map((relation) => {
return {
...relation,
status,
};
}),
};
return;
}
// No specific status was requested, we should find the latest available status for each relation
const documentMetadata = getService('document-metadata');
// Get any available statuses for the returned relations
const documentsAvailableStatus = await documentMetadata.getManyAvailableStatus(
targetUid,
res.results
);
const res = await strapi.db
.query(targetUid)
.findPage(convertQueryParams.transformParamsToQuery(targetUid, queryParams));
ctx.body = {
...res,
results: res.results.map((relation) => {
const availableStatuses =
documentsAvailableStatus.filter(
(availableDocument: RelationEntity) =>
availableDocument.documentId === relation.documentId
) ?? [];
return {
...relation,
status: documentMetadata.getStatus(relation, availableStatuses),
};
}),
results: await addStatusToRelations(targetUid, res.results),
};
},
async findAvailable(ctx: any) {
const { id } = ctx.request.query;
await validateFindAvailable(ctx.request.query);
await this.find(ctx, id, true);
},
async findExisting(ctx: any) {
const { userAbility } = ctx.state;
const { id } = ctx.params;
await validateFindExisting(ctx.request.query);
await this.find(ctx, id, false);
const locale = ctx.request?.query?.locale || null;
const status = ctx.request?.query?.status;
const {
entryId,
attribute,
targetField,
fieldsToSelect,
source: {
schema: { uid: sourceUid },
},
target: {
schema: { uid: targetUid },
},
} = await this.extractAndValidateRequestInfo(ctx, id, locale, status);
const permissionQuery = await getService('permission-checker')
.create({ userAbility, model: targetUid })
.sanitizedQuery.read({ fields: fieldsToSelect });
/**
* loadPages can not be used for single relations,
* this unifies the loading regardless of it's type
*
* NOTE: Relations need to be loaded using any db.query method
* to ensure the proper ordering is applied
*/
const dbQuery = strapi.db.query(sourceUid);
const loadRelations = relations.isAnyToMany(attribute)
? (...args: Parameters<typeof dbQuery.loadPages>) => dbQuery.loadPages(...args)
: (...args: Parameters<typeof dbQuery.load>) =>
dbQuery
.load(...args)
// Ensure response is an array
.then((res) => ({ results: res ? [res] : [] }));
/**
* If user does not have access to specific relations (custom conditions),
* only the ids of the relations are returned.
*
* - First query loads all the ids.
* - Second one also loads the main field, and excludes forbidden relations.
*
* The response contains the union of the two queries.
*/
const res = await loadRelations({ id: entryId }, targetField, {
select: ['id', 'documentId', 'locale', 'publishedAt'],
ordering: 'desc',
page: ctx.request.query.page,
pageSize: ctx.request.query.pageSize,
});
/**
* Add all ids to load in permissionQuery
* If any of the relations are not accessible, the permissionQuery will exclude them
*/
const loadedIds = res.results.map((item: any) => item.id);
addFiltersClause(permissionQuery, { id: { $in: loadedIds } });
const sanitizedRes = await loadRelations({ id: entryId }, targetField, {
...convertQueryParams.transformParamsToQuery(targetUid, permissionQuery),
ordering: 'desc',
page: ctx.request.query.page,
pageSize: ctx.request.query.pageSize,
});
const relationsUnion = uniqBy('id', concat(sanitizedRes.results, res.results));
ctx.body = {
pagination: res.pagination,
results: await addStatusToRelations(targetUid, relationsUnion),
};
},
};

View File

@ -8,7 +8,7 @@ export interface RelationResult {
id: number;
status: Documents.Params.PublicationStatus.Kind;
locale?: Documents.Params.Locale;
[key: string]: unknown;
[key: string]: any;
}
export interface Pagination {