diff --git a/api-tests/core/content-manager/find-relations.test.api.js b/api-tests/core/content-manager/find-relations.test.api.js index aa6980569f..70d125d646 100644 --- a/api-tests/core/content-manager/find-relations.test.api.js +++ b/api-tests/core/content-manager/find-relations.test.api.js @@ -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', diff --git a/api-tests/plugins/i18n/content-manager/find-available-relations.test.api.js b/api-tests/plugins/i18n/content-manager/find-available-relations.test.api.js index 61d3938732..67be560ae1 100644 --- a/api-tests/plugins/i18n/content-manager/find-available-relations.test.api.js +++ b/api-tests/plugins/i18n/content-manager/find-available-relations.test.api.js @@ -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); diff --git a/api-tests/plugins/i18n/content-manager/find-existing-relations.test.api.js b/api-tests/plugins/i18n/content-manager/find-existing-relations.test.api.js index ca169b0a35..77e7a798f0 100644 --- a/api-tests/plugins/i18n/content-manager/find-existing-relations.test.api.js +++ b/api-tests/plugins/i18n/content-manager/find-existing-relations.test.api.js @@ -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); diff --git a/examples/getstarted/src/index.js b/examples/getstarted/src/index.js index 5abfcc85cf..a1dd6da33e 100644 --- a/examples/getstarted/src/index.js +++ b/examples/getstarted/src/index.js @@ -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 diff --git a/packages/core/admin/admin/src/components/Form.tsx b/packages/core/admin/admin/src/components/Form.tsx index b34fe474de..b7f9419972 100644 --- a/packages/core/admin/admin/src/components/Form.tsx +++ b/packages/core/admin/admin/src/components/Form.tsx @@ -560,7 +560,6 @@ const reducer = ( */ const currentField = [...(getIn(state.values, field, []) as Array)]; const currentRow = currentField[fromIndex]; - const newIndex = action.payload.toIndex; const startKey = fromIndex > toIndex @@ -573,7 +572,7 @@ const reducer = ( 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); diff --git a/packages/core/admin/admin/src/content-manager/pages/ComponentConfigurationPage.tsx b/packages/core/admin/admin/src/content-manager/pages/ComponentConfigurationPage.tsx index 3f450fad7d..844b657260 100644 --- a/packages/core/admin/admin/src/content-manager/pages/ComponentConfigurationPage.tsx +++ b/packages/core/admin/admin/src/content-manager/pages/ComponentConfigurationPage.tsx @@ -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 ( - + <> + + + ); }; diff --git a/packages/core/admin/admin/src/content-manager/pages/EditConfigurationPage.tsx b/packages/core/admin/admin/src/content-manager/pages/EditConfigurationPage.tsx index 2809e070e1..ece656a962 100644 --- a/packages/core/admin/admin/src/content-manager/pages/EditConfigurationPage.tsx +++ b/packages/core/admin/admin/src/content-manager/pages/EditConfigurationPage.tsx @@ -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 ( - + <> + + + ); }; diff --git a/packages/core/admin/admin/src/content-manager/pages/EditView/components/FormInputs/Relations.tsx b/packages/core/admin/admin/src/content-manager/pages/EditView/components/FormInputs/Relations.tsx index 810f2c6655..22e32ae014 100644 --- a/packages/core/admin/admin/src/content-manager/pages/EditView/components/FormInputs/Relations.tsx +++ b/packages/core/admin/admin/src/content-manager/pages/EditView/components/FormInputs/Relations.tsx @@ -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 & { + before: string; + end?: never; + }) + | { end: boolean; before?: never; status?: never; locale?: never }; + +interface Relation extends Pick { href: string; label: string; - [key: string]: any; + position?: RelationPosition; + __temp_key__: string; } interface RelationsFieldProps @@ -62,8 +73,8 @@ interface RelationsFieldProps Pick {} interface RelationsFormValue { - connect?: Contracts.Relations.RelationResult[]; - disconnect?: Contracts.Relations.RelationResult[]; + connect?: Relation[]; + disconnect?: Pick[]; } /** @@ -140,17 +151,29 @@ const RelationsField = React.forwardRef( setCurrentPage((prev) => prev + 1); }; - const field = useField(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( 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( 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 ( ( id={id} label={`${label} ${relationsCount > 0 ? `(${relationsCount})` : ''}`} model={model} + onChange={handleConnect} {...props} /> {'pagination' in data && @@ -207,6 +271,7 @@ const RelationsField = React.forwardRef( { 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 { id?: string; model: string; + onChange: ( + relation: Pick & { + [key: string]: any; + } + ) => void; } /** @@ -288,7 +374,7 @@ const RelationsInput = ({ mainField, placeholder, required, - attribute, + onChange, }: RelationsInputProps) => { const [textValue, setTextValue] = React.useState(''); const [searchParams, setSearchParams] = React.useState({ @@ -301,7 +387,6 @@ const RelationsInput = ({ const { formatMessage } = useIntl(); const fieldRef = useFocusInputField(name); const field = useField(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 { +const RelationsList = ({ + data, + serverData, + disabled, + name, + isLoading, + relationType, +}: RelationsListProps) => { const ariaDescriptionId = React.useId(); const { formatMessage } = useIntl(); const listRef = React.useRef(null); const outerListRef = React.useRef(null); const [overflow, setOverflow] = React.useState<'top' | 'bottom' | 'top-bottom'>(); const [liveText, setLiveText] = React.useState(''); - const field = useField(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((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[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(relationRef, dragRef); diff --git a/packages/core/admin/admin/src/content-manager/pages/ListConfiguration/ListConfigurationPage.tsx b/packages/core/admin/admin/src/content-manager/pages/ListConfiguration/ListConfigurationPage.tsx index 82bd03314f..04fccffd07 100644 --- a/packages/core/admin/admin/src/content-manager/pages/ListConfiguration/ListConfigurationPage.tsx +++ b/packages/core/admin/admin/src/content-manager/pages/ListConfiguration/ListConfigurationPage.tsx @@ -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 ( +
; pagination: { page: NonNullable; pageSize: NonNullable; @@ -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 }; diff --git a/packages/core/admin/admin/src/translations/en.json b/packages/core/admin/admin/src/translations/en.json index 5353265c85..c997060522 100644 --- a/packages/core/admin/admin/src/translations/en.json +++ b/packages/core/admin/admin/src/translations/en.json @@ -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", diff --git a/packages/core/content-manager/server/src/controllers/relations.ts b/packages/core/content-manager/server/src/controllers/relations.ts index 860ed5270d..5471297d66 100644 --- a/packages/core/content-manager/server/src/controllers/relations.ts +++ b/packages/core/content-manager/server/src/controllers/relations.ts @@ -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 = {}; - 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) => dbQuery.loadPages(...args) + : (...args: Parameters) => + 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), + }; }, }; diff --git a/packages/core/content-manager/shared/contracts/relations.ts b/packages/core/content-manager/shared/contracts/relations.ts index 56b2a1a502..076c791e54 100644 --- a/packages/core/content-manager/shared/contracts/relations.ts +++ b/packages/core/content-manager/shared/contracts/relations.ts @@ -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 {