mirror of
https://github.com/strapi/strapi.git
synced 2025-12-28 15:44:59 +00:00
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:
parent
f62c536bfa
commit
98ecaa5093
@ -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',
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user