diff --git a/api-tests/plugins/i18n/content-api/content-types.test.api.js b/api-tests/plugins/i18n/content-api/content-types.test.api.js index cd3f31dcfd..ac1be9492e 100644 --- a/api-tests/plugins/i18n/content-api/content-types.test.api.js +++ b/api-tests/plugins/i18n/content-api/content-types.test.api.js @@ -92,6 +92,7 @@ const dogs = [ description: 'A good girl', myCompo: { name: 'my compo' }, myDz: [{ name: 'my compo', __component: 'default.simple' }], + locale: 'en', }, ]; @@ -121,14 +122,20 @@ describe('i18n - Content API', () => { data.dogs = await builder.sanitizedFixturesFor(dogSchema.singularName, strapi); data.categories = await builder.sanitizedFixturesFor(categoryModel.singularName, strapi); - const { body } = await rq({ + + // Create a new locale of the same document + const { locale, name, ...partialDog } = dogs[0]; + await rq({ method: 'PUT', - url: `/content-manager/collection-types/api::dog.dog/${data.dogs[0].id}`, + url: `/content-manager/collection-types/api::dog.dog/${data.dogs[0].documentId}`, body: { + ...partialDog, categories: [data.categories[0].id], }, + qs: { + locale: 'fr', + }, }); - data.dogs[0] = body; }); afterAll(async () => { @@ -139,8 +146,7 @@ describe('i18n - Content API', () => { await builder.cleanup(); }); - // V5: Fix non localized attributes - describe.skip('Test content-types', () => { + describe('Test content-types', () => { describe('getNonLocalizedAttributes', () => { test('Get non localized attributes (including compo and dz)', async () => { const res = await rq({ @@ -148,7 +154,7 @@ describe('i18n - Content API', () => { url: '/i18n/content-manager/actions/get-non-localized-fields', body: { id: data.dogs[0].id, - locale: 'fr', + locale: data.dogs[0].locale, model: 'api::dog.dog', }, }); @@ -165,7 +171,10 @@ describe('i18n - Content API', () => { }, ], }, - localizations: [{ id: 1, locale: 'en' }], + localizations: [ + { id: 2, locale: 'fr' }, + { id: 1, locale: 'en' }, + ], }); }); }); diff --git a/api-tests/plugins/i18n/content-manager/non-localized-fields.test.api.js b/api-tests/plugins/i18n/content-manager/non-localized-fields.test.api.js new file mode 100644 index 0000000000..2c43051db1 --- /dev/null +++ b/api-tests/plugins/i18n/content-manager/non-localized-fields.test.api.js @@ -0,0 +1,548 @@ +'use strict'; + +const { createStrapiInstance } = require('api-tests/strapi'); +const { createAuthRequest } = require('api-tests/request'); +const { createTestBuilder } = require('api-tests/builder'); +const { set } = require('lodash/fp'); + +const modelsUtils = require('api-tests/models'); +const { cloneDeep } = require('lodash'); + +let strapi; +let rq; + +const categoryModel = { + kind: 'collectionType', + collectionName: 'categories', + displayName: 'Category', + singularName: 'category', + pluralName: 'categories', + description: '', + name: 'Category', + draftAndPublish: true, + pluginOptions: { + i18n: { + localized: true, + }, + }, + attributes: { + name: { + type: 'string', + unique: true, + pluginOptions: { + i18n: { + localized: true, + }, + }, + }, + nonLocalized: { + type: 'string', + pluginOptions: { + i18n: { + localized: false, + }, + }, + }, + nonLocalizedCompo: { + component: 'default.compo', + type: 'component', + repeatable: false, + pluginOptions: { + i18n: { + localized: false, + }, + }, + }, + nonLocalizedRepeatableCompo: { + component: 'default.compo', + type: 'component', + repeatable: true, + pluginOptions: { + i18n: { + localized: false, + }, + }, + }, + }, +}; + +const tagModel = { + kind: 'collectionType', + collectionName: 'tags', + displayName: 'Tag', + singularName: 'tag', + pluralName: 'tags', + description: '', + options: { + reviewWorkflows: false, + draftAndPublish: true, + }, + pluginOptions: { + i18n: { + localized: true, + }, + }, + attributes: { + name: { + type: 'string', + pluginOptions: { + i18n: { + localized: true, + }, + }, + }, + nonLocalized: { + type: 'string', + pluginOptions: { + i18n: { + localized: false, + }, + }, + }, + }, +}; + +const compo = (withRelations = false) => ({ + displayName: 'compo', + category: 'default', + attributes: { + name: { + type: 'string', + }, + ...(!withRelations + ? {} + : { + tag: { + type: 'relation', + relation: 'oneToOne', + target: 'api::tag.tag', + }, + }), + }, +}); + +const data = { + tags: [], +}; + +const allLocales = [ + { code: 'ko', name: 'Korean' }, + { code: 'it', name: 'Italian' }, + { code: 'fr', name: 'French' }, + { code: 'es-AR', name: 'Spanish (Argentina)' }, +]; + +const allLocaleCodes = allLocales.map((locale) => locale.code); + +// Make the tags available in all locales except one so we can test relation cases +// when the locale relation does not exist +const tagsAvailableIn = allLocaleCodes.slice(1); + +const transformConnectToDisconnect = (data) => { + const transformObject = (obj) => { + if (obj.tag && obj.tag.connect) { + obj.tag.disconnect = obj.tag.connect; + delete obj.tag.connect; + } + }; + + if (Array.isArray(data)) { + data.forEach((item) => transformObject(item)); + } else if (typeof data === 'object' && data !== null) { + transformObject(data); + } + + return data; +}; + +describe('i18n', () => { + const builder = createTestBuilder(); + + beforeAll(async () => { + await builder + .addComponent(compo(false)) + .addContentTypes([tagModel, categoryModel]) + .addFixtures('plugin::i18n.locale', [ + { name: 'Korean', code: 'ko' }, + { name: 'Italian', code: 'it' }, + { name: 'French', code: 'fr' }, + { name: 'Spanish (Argentina)', code: 'es-AR' }, + ]) + .build(); + + await modelsUtils.modifyComponent(compo(true)); + + strapi = await createStrapiInstance(); + rq = await createAuthRequest({ strapi }); + }); + + afterAll(async () => { + // Delete all locales that have been created + await strapi.db.query('plugin::i18n.locale').deleteMany({ code: { $ne: 'en' } }); + + await strapi.destroy(); + await builder.cleanup(); + }); + + describe('Non localized fields', () => { + let documentId = ''; + + beforeAll(async () => { + // Create a document with an entry in every locale with the localized + // field filled in. This field can be different across locales + const res = await rq({ + method: 'POST', + url: `/content-manager/collection-types/api::category.category`, + body: { + name: `Test`, + }, + }); + documentId = res.body.data.documentId; + + for (const locale of allLocaleCodes) { + await rq({ + method: 'PUT', + url: `/content-manager/collection-types/api::category.category/${documentId}`, + body: { + locale, + name: `Test ${locale}`, + }, + }); + } + + // Create 2 tags in the default locale + const [tag1, tag2] = await Promise.all([ + rq({ + method: 'POST', + url: `/content-manager/collection-types/api::tag.tag`, + body: { + name: `Test tag`, + }, + }), + rq({ + method: 'POST', + url: `/content-manager/collection-types/api::tag.tag`, + body: { + name: `Test tag 2`, + }, + }), + ]); + data.tags.push(tag1.body.data); + data.tags.push(tag2.body.data); + + for (const locale of tagsAvailableIn) { + // Create 2 tags for every other locale that supports tags + const [localeTag1, localeTag2] = await Promise.all([ + rq({ + method: 'PUT', + url: `/content-manager/collection-types/api::tag.tag/${tag1.body.data.documentId}`, + body: { + locale, + name: `Test tag ${locale}`, + }, + }), + rq({ + method: 'PUT', + url: `/content-manager/collection-types/api::tag.tag/${tag2.body.data.documentId}`, + body: { + locale, + name: `Test tag ${locale} 2`, + }, + }), + ]); + + data.tags.push(localeTag1.body.data); + data.tags.push(localeTag2.body.data); + } + }); + + // Test non localized behaviour across these actions + const actionsToTest = [['publish'], ['unpublish + discard'], ['update']]; + + describe('Scalar non localized fields', () => { + describe.each(actionsToTest)('', (method) => { + test(`Modify a scalar non localized field - Method ${method}`, async () => { + const isPublish = method === 'publish'; + const isUnpublish = method.includes('unpublish'); + + const key = 'nonLocalized'; + // Update the non localized field + const updatedValue = `${key}::Update Test::${method}`; + + let res; + if (isPublish) { + // Publish the default locale entry + res = await rq({ + method: 'POST', + url: `/content-manager/collection-types/api::category.category/${documentId}/actions/publish`, + body: { + [key]: updatedValue, + }, + }); + } else if (isUnpublish) { + // Publish the default locale entry + await rq({ + method: 'POST', + url: `/content-manager/collection-types/api::category.category/${documentId}/actions/publish`, + body: { + [key]: updatedValue, + }, + }); + + // Update the default locale draft entry with random data + const randomData = 'random'; + await rq({ + method: 'PUT', + url: `/content-manager/collection-types/api::category.category/${documentId}`, + body: { + [key]: randomData, + }, + }); + + // Unpublish the default locale entry + res = await rq({ + method: 'POST', + url: `/content-manager/collection-types/api::category.category/${documentId}/actions/unpublish`, + body: { + discardDraft: true, + }, + }); + } else { + res = await rq({ + method: 'PUT', + url: `/content-manager/collection-types/api::category.category/${documentId}`, + body: { + [key]: updatedValue, + }, + }); + } + + for (const locale of allLocaleCodes) { + const localeRes = await strapi.db.query('api::category.category').findOne({ + where: { + documentId, + publishedAt: null, + locale: { $eq: locale }, + }, + }); + + // The locale should now have the same value as the default locale. + expect(localeRes[key]).toEqual(updatedValue); + } + }); + }); + }); + + describe('Scalar field within a non localized component', () => { + describe.each(actionsToTest)('', (method) => { + test(`Modify a scalar field within a non localized component - Method ${method}`, async () => { + const isPublish = method === 'publish'; + const isUnpublish = method.includes('unpublish'); + + const key = 'nonLocalizedCompo'; + const updateAt = [{ key: 'name', value: 'Compo Name' }]; + + const updatedValue = updateAt.reduce((acc, { key, value }) => { + return set(key, `${key}::${value}::${method}`, acc); + }, {}); + + if (isPublish) { + // Publish the default locale entry + await rq({ + method: 'POST', + url: `/content-manager/collection-types/api::category.category/${documentId}/actions/publish`, + body: { + [key]: updatedValue, + }, + }); + } else if (isUnpublish) { + // Publish the default locale entry + await rq({ + method: 'POST', + url: `/content-manager/collection-types/api::category.category/${documentId}/actions/publish`, + body: { + [key]: updatedValue, + }, + }); + + let randomData = {}; + Object.entries(updatedValue).forEach(([key, value]) => { + if (typeof value === 'string') { + randomData[key] = 'random'; + } else { + randomData[key] = value; + } + }); + + // Update the default locale draft entry with random data + await rq({ + method: 'PUT', + url: `/content-manager/collection-types/api::category.category/${documentId}`, + body: { + [key]: randomData, + }, + }); + + // Unpublish the default locale entry + await rq({ + method: 'POST', + url: `/content-manager/collection-types/api::category.category/${documentId}/actions/unpublish`, + body: { + discardDraft: true, + }, + }); + } else { + await rq({ + method: 'PUT', + url: `/content-manager/collection-types/api::category.category/${documentId}`, + body: { + [key]: updatedValue, + }, + }); + } + + for (const locale of allLocaleCodes) { + const localeRes = await strapi.db.query('api::category.category').findOne({ + where: { + documentId, + publishedAt: null, + locale: { $eq: locale }, + }, + populate: [key], + }); + + // Make sure non localized component fields in other locales have been updated in the same way. + expect(localeRes[key]).toEqual(expect.objectContaining(updatedValue)); + } + }); + }); + }); + + describe.each([false, true])('', (isRepeatable) => { + describe('Relation within a non localized component', () => { + describe.each(actionsToTest)('', (method) => { + test(`Modify a relation within a non localized component - Method ${method} - Repeatable ${isRepeatable}`, async () => { + const isPublish = method === 'publish'; + const isUnpublish = method.includes('unpublish'); + + const key = isRepeatable ? 'nonLocalizedRepeatableCompo' : 'nonLocalizedCompo'; + const connectRelationAt = 'tag'; + + let updatedValue; + if (isRepeatable) { + const localeTags = data.tags.filter((tag) => tag.locale === 'en'); + + updatedValue = [ + { + [connectRelationAt]: { + connect: [localeTags[0]], + }, + }, + { + [connectRelationAt]: { + connect: [localeTags[1]], + }, + }, + ]; + } else { + updatedValue = { + [connectRelationAt]: { + connect: [data.tags.find((tag) => tag.locale === 'en')], + }, + }; + } + + let res; + if (isPublish) { + // Publish the default locale entry + res = await rq({ + method: 'POST', + url: `/content-manager/collection-types/api::category.category/${documentId}/actions/publish`, + body: { + [key]: updatedValue, + }, + }); + } else if (isUnpublish) { + // Publish the default locale entry + await rq({ + method: 'POST', + url: `/content-manager/collection-types/api::category.category/${documentId}/actions/publish`, + body: { + [key]: updatedValue, + }, + }); + + // Update the default locale draft entry to remove any connected tags + await rq({ + method: 'PUT', + url: `/content-manager/collection-types/api::category.category/${documentId}`, + body: { + [key]: transformConnectToDisconnect(cloneDeep(updatedValue)), + }, + }); + + // Unpublish the default locale entry + res = await rq({ + method: 'POST', + url: `/content-manager/collection-types/api::category.category/${documentId}/actions/unpublish`, + body: { + discardDraft: true, + }, + }); + } else { + res = await rq({ + method: 'PUT', + url: `/content-manager/collection-types/api::category.category/${documentId}`, + body: { + [key]: updatedValue, + }, + }); + } + + // If we have connected a relation, we should expect the count to + // equal the number of relations we have connected + Array.isArray(res.body.data[key]) + ? res.body.data[key] + : [res.body.data[key]].forEach((item, index) => { + expect(item[connectRelationAt].count).toEqual( + Array.isArray(updatedValue) + ? updatedValue[index][connectRelationAt].connect.length + : updatedValue[connectRelationAt].connect.length + ); + }); + + for (const locale of allLocaleCodes) { + const localeRes = await strapi.db.query('api::category.category').findOne({ + where: { + documentId, + publishedAt: null, + locale: { $eq: locale }, + }, + populate: [`${key}.${connectRelationAt}`], + }); + + // Connecting a relation to the default locale should add the + // equivalent locale relation if it exists to the other locales + (Array.isArray(localeRes[key]) ? localeRes[key] : [localeRes[key]]).forEach( + (item, index) => { + if (!tagsAvailableIn.includes(locale)) { + expect(item[connectRelationAt]).toBeNull(); + } else { + expect(item[connectRelationAt]).toEqual( + expect.objectContaining({ + locale, + documentId: (Array.isArray(updatedValue) ? updatedValue : [updatedValue])[ + index + ][connectRelationAt].connect[0].documentId, + }) + ); + } + } + ); + } + }); + }); + }); + }); + }); +}); diff --git a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/BlocksInput/BlocksInput.tsx b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/BlocksInput/BlocksInput.tsx index c84b11d480..a230be6461 100644 --- a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/BlocksInput/BlocksInput.tsx +++ b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/BlocksInput/BlocksInput.tsx @@ -8,18 +8,19 @@ import { BlocksEditor } from './BlocksEditor'; import type { Schema } from '@strapi/types'; interface BlocksInputProps extends Omit { + labelAction?: React.ReactNode; type: Schema.Attribute.Blocks['type']; } const BlocksInput = React.forwardRef<{ focus: () => void }, BlocksInputProps>( - ({ label, name, required = false, hint, ...editorProps }, forwardedRef) => { + ({ label, name, required = false, hint, labelAction, ...editorProps }, forwardedRef) => { const id = React.useId(); const field = useField(name); return ( - {label} + {label} , 'size' | 'hint'>, - Pick {} + Pick { + labelAction?: React.ReactNode; +} const ComponentInput = ({ label, @@ -23,6 +27,7 @@ const ComponentInput = ({ name, attribute, disabled, + labelAction, ...props }: ComponentInputProps) => { const { formatMessage } = useIntl(); @@ -57,7 +62,7 @@ const ComponentInput = ({ )} {required && *} - {/* {labelAction && {labelAction}} */} + {labelAction && {labelAction}} {showResetComponent && ( diff --git a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/DynamicZone/Field.tsx b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/DynamicZone/Field.tsx index dc3331f399..7be9162930 100644 --- a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/DynamicZone/Field.tsx +++ b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/DynamicZone/Field.tsx @@ -21,7 +21,7 @@ import { ComponentProvider, useComponent } from '../ComponentContext'; import { AddComponentButton } from './AddComponentButton'; import { ComponentPicker } from './ComponentPicker'; import { DynamicComponent, DynamicComponentProps } from './DynamicComponent'; -import { DynamicZoneLabel } from './DynamicZoneLabel'; +import { DynamicZoneLabel, DynamicZoneLabelProps } from './DynamicZoneLabel'; import type { Schema } from '@strapi/types'; @@ -38,13 +38,15 @@ const [DynamicZoneProvider, useDynamicZone] = createContext, 'size' | 'hint'>, - Pick {} + Pick, + Pick {} const DynamicZone = ({ attribute, disabled, hint, label, + labelAction, name, required = false, }: DynamicZoneProps) => { @@ -244,7 +246,7 @@ const DynamicZone = ({ { * for relations and then add them to the field's connect array. */ const RelationsInput = ({ - disabled, - hint, id, - label, model, name, mainField, placeholder, - required, + unique: _unique, + 'aria-label': _ariaLabel, onChange, + ...props }: RelationsInputProps) => { const [textValue, setTextValue] = React.useState(''); const [searchParams, setSearchParams] = React.useState({ @@ -490,13 +489,9 @@ const RelationsInput = ({ return ( { handleSearch(event.currentTarget.value); }} + {...props} > {options.map((opt) => { const textValue = getRelationLabel(opt, mainField); diff --git a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/UID.tsx b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/UID.tsx index 0e6a1bdc74..97d126735a 100644 --- a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/UID.tsx +++ b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/UID.tsx @@ -36,230 +36,224 @@ interface UIDInputProps extends Omit { type: Schema.Attribute.TypeOf; } -const UIDInput = React.forwardRef( - ({ hint, disabled, label, name, placeholder, required }, ref) => { - const { model, id } = useDoc(); - const allFormValues = useForm('InputUID', (form) => form.values); - const [availability, setAvailability] = React.useState(); - const [showRegenerate, setShowRegenerate] = React.useState(false); - const field = useField(name); - const debouncedValue = useDebounce(field.value, 300); - const { toggleNotification } = useNotification(); - const { _unstableFormatAPIError: formatAPIError } = useAPIErrorHandler(); - const { formatMessage } = useIntl(); - const [{ query }] = useQueryParams(); - const params = React.useMemo(() => buildValidParams(query), [query]); +const UIDInput = React.forwardRef((props, ref) => { + const { model, id } = useDoc(); + const allFormValues = useForm('InputUID', (form) => form.values); + const [availability, setAvailability] = React.useState(); + const [showRegenerate, setShowRegenerate] = React.useState(false); + const field = useField(props.name); + const debouncedValue = useDebounce(field.value, 300); + const { toggleNotification } = useNotification(); + const { _unstableFormatAPIError: formatAPIError } = useAPIErrorHandler(); + const { formatMessage } = useIntl(); + const [{ query }] = useQueryParams(); + const params = React.useMemo(() => buildValidParams(query), [query]); - const { - data: defaultGeneratedUID, - isLoading: isGeneratingDefaultUID, - error: apiError, - } = useGetDefaultUIDQuery( - { - contentTypeUID: model, - field: name, - data: { - id: id ?? '', - ...allFormValues, - }, - params, + const { + data: defaultGeneratedUID, + isLoading: isGeneratingDefaultUID, + error: apiError, + } = useGetDefaultUIDQuery( + { + contentTypeUID: model, + field: props.name, + data: { + id: id ?? '', + ...allFormValues, }, - { - skip: field.value || !required, - } - ); + params, + }, + { + skip: field.value || !props.required, + } + ); - React.useEffect(() => { - if (apiError) { + React.useEffect(() => { + if (apiError) { + toggleNotification({ + type: 'warning', + message: formatAPIError(apiError), + }); + } + }, [apiError, formatAPIError, toggleNotification]); + + /** + * If the defaultGeneratedUID is available, then we set it as the value, + * but we also want to set it as the initialValue too. + */ + React.useEffect(() => { + if (defaultGeneratedUID && field.value === undefined) { + field.onChange(props.name, defaultGeneratedUID); + } + }, [defaultGeneratedUID, field, props.name]); + + const [generateUID, { isLoading: isGeneratingUID }] = useGenerateUIDMutation(); + + const handleRegenerateClick = async () => { + try { + const res = await generateUID({ + contentTypeUID: model, + field: props.name, + data: { id: id ?? '', ...allFormValues }, + params, + }); + + if ('data' in res) { + field.onChange(props.name, res.data); + } else { toggleNotification({ type: 'danger', - message: formatAPIError(apiError), + message: formatAPIError(res.error), }); } - }, [apiError, formatAPIError, toggleNotification]); + } catch (err) { + toggleNotification({ + type: 'danger', + message: formatMessage({ + id: 'notification.error', + defaultMessage: 'An error occurred.', + }), + }); + } + }; + const { + data: availabilityData, + isLoading: isCheckingAvailability, + error: availabilityError, + } = useGetAvailabilityQuery( + { + contentTypeUID: model, + field: props.name, + value: debouncedValue ? debouncedValue.trim() : '', + params, + }, + { + skip: !Boolean( + debouncedValue !== field.initialValue && + debouncedValue && + UID_REGEX.test(debouncedValue.trim()) + ), + } + ); + + React.useEffect(() => { + if (availabilityError) { + toggleNotification({ + type: 'warning', + message: formatAPIError(availabilityError), + }); + } + }, [availabilityError, formatAPIError, toggleNotification]); + + React.useEffect(() => { /** - * If the defaultGeneratedUID is available, then we set it as the value, - * but we also want to set it as the initialValue too. + * always store the data in state because that way as seen below + * we can then remove the data to stop showing the label. */ - React.useEffect(() => { - if (defaultGeneratedUID && field.value === undefined) { - field.onChange(name, defaultGeneratedUID); - } - }, [defaultGeneratedUID, field, name]); + setAvailability(availabilityData); - const [generateUID, { isLoading: isGeneratingUID }] = useGenerateUIDMutation(); + let timer: number; - const handleRegenerateClick = async () => { - try { - const res = await generateUID({ - contentTypeUID: model, - field: name, - data: { id: id ?? '', ...allFormValues }, - params, - }); + if (availabilityData?.isAvailable) { + timer = window.setTimeout(() => { + setAvailability(undefined); + }, 4000); + } - if ('data' in res) { - field.onChange(name, res.data); - } else { - toggleNotification({ - type: 'danger', - message: formatAPIError(res.error), - }); - } - } catch (err) { - toggleNotification({ - type: 'danger', - message: formatMessage({ - id: 'notification.error', - defaultMessage: 'An error occurred.', - }), - }); + return () => { + if (timer) { + clearTimeout(timer); } }; + }, [availabilityData]); - const { - data: availabilityData, - isLoading: isCheckingAvailability, - error: availabilityError, - } = useGetAvailabilityQuery( - { - contentTypeUID: model, - field: name, - value: debouncedValue ? debouncedValue.trim() : '', - params, - }, - { - skip: !Boolean( - debouncedValue !== field.initialValue && - debouncedValue && - UID_REGEX.test(debouncedValue.trim()) - ), - } - ); + const isLoading = isGeneratingDefaultUID || isGeneratingUID || isCheckingAvailability; - React.useEffect(() => { - if (availabilityError) { - toggleNotification({ - type: 'danger', - message: formatAPIError(availabilityError), - }); - } - }, [availabilityError, formatAPIError, toggleNotification]); + const fieldRef = useFocusInputField(props.name); + const composedRefs = useComposedRefs(ref, fieldRef); - React.useEffect(() => { - /** - * always store the data in state because that way as seen below - * we can then remove the data to stop showing the label. - */ - setAvailability(availabilityData); + return ( + // @ts-expect-error – label _could_ be a ReactNode since it's a child, this should be fixed in the DS. + + {availability && !showRegenerate && ( + + {availability?.isAvailable ? : } - let timer: number; - - if (availabilityData?.isAvailable) { - timer = window.setTimeout(() => { - setAvailability(undefined); - }, 4000); - } - - return () => { - if (timer) { - clearTimeout(timer); - } - }; - }, [availabilityData]); - - const isLoading = isGeneratingDefaultUID || isGeneratingUID || isCheckingAvailability; - - const fieldRef = useFocusInputField(name); - const composedRefs = useComposedRefs(ref, fieldRef); - - return ( - - {availability && !showRegenerate && ( - - {availability?.isAvailable ? : } - - - {formatMessage( - availability.isAvailable - ? { - id: 'content-manager.components.uid.available', - defaultMessage: 'Available', - } - : { - id: 'content-manager.components.uid.unavailable', - defaultMessage: 'Unavailable', - } - )} - - - )} - - {!disabled && ( - <> - {showRegenerate && ( - - - {formatMessage({ - id: 'content-manager.components.uid.regenerate', - defaultMessage: 'Regenerate', - })} - - + {formatMessage( + availability.isAvailable + ? { + id: 'content-manager.components.uid.available', + defaultMessage: 'Available', + } + : { + id: 'content-manager.components.uid.unavailable', + defaultMessage: 'Unavailable', + } )} + + + )} - setShowRegenerate(true)} - onMouseLeave={() => setShowRegenerate(false)} - > - {isLoading ? ( - - - - ) : ( - - )} - - - )} - - } - hint={hint} - // @ts-expect-error – label _could_ be a ReactNode since it's a child, this should be fixed in the DS. - label={label} - name={name} - onChange={field.onChange} - placeholder={placeholder} - value={field.value ?? ''} - required={required} - /> - ); - } -); + {!props.disabled && ( + <> + {showRegenerate && ( + + + {formatMessage({ + id: 'content-manager.components.uid.regenerate', + defaultMessage: 'Regenerate', + })} + + + )} + + setShowRegenerate(true)} + onMouseLeave={() => setShowRegenerate(false)} + > + {isLoading ? ( + + + + ) : ( + + )} + + + )} + + } + onChange={field.onChange} + value={field.value ?? ''} + {...props} + /> + ); +}); /* ------------------------------------------------------------------------------------------------- * FieldActionWrapper diff --git a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Wysiwyg/Field.tsx b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Wysiwyg/Field.tsx index c5537c8035..92a800952c 100644 --- a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Wysiwyg/Field.tsx +++ b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Wysiwyg/Field.tsx @@ -26,7 +26,7 @@ interface WysiwygProps extends Omit { } const Wysiwyg = React.forwardRef( - ({ hint, disabled, label, name, placeholder, required }, forwardedRef) => { + ({ hint, disabled, label, name, placeholder, required, labelAction }, forwardedRef) => { const field = useField(name); const textareaRef = React.useRef(null); const editorRef = React.useRef( @@ -105,7 +105,7 @@ const Wysiwyg = React.forwardRef( return ( - {label} + {label} { context.action === 'create' ? // @ts-expect-error The context args are not typed correctly { documentId: result.documentId, locale: context.args[0]?.locale } - : { documentId: context.args[0], locale: context.args[1]?.locale }; + : // @ts-expect-error The context args are not typed correctly + { documentId: context.args[0], locale: context.args[1]?.locale }; const locale = documentContext.locale ?? (await localesService.getDefaultLocale()); const document = await strapi diff --git a/packages/core/content-manager/server/src/services/document-metadata.ts b/packages/core/content-manager/server/src/services/document-metadata.ts index 7895eea62e..4e14aa4b0e 100644 --- a/packages/core/content-manager/server/src/services/document-metadata.ts +++ b/packages/core/content-manager/server/src/services/document-metadata.ts @@ -157,9 +157,9 @@ export default ({ strapi }: { strapi: Core.LoadedStrapi }) => ({ getStatus(version: DocumentVersion, otherDocumentStatuses?: DocumentMetadata['availableStatus']) { const isDraft = version.publishedAt === null; - // It can only be a draft if there are no other versions if (!otherDocumentStatuses?.length) { - return CONTENT_MANAGER_STATUS.DRAFT; + // It there are no other versions we take the current version status + return isDraft ? CONTENT_MANAGER_STATUS.DRAFT : CONTENT_MANAGER_STATUS.PUBLISHED; } // Check if there is only a draft version diff --git a/packages/core/content-manager/shared/contracts/collection-types.ts b/packages/core/content-manager/shared/contracts/collection-types.ts index d7df5b7279..858b8e8ce8 100644 --- a/packages/core/content-manager/shared/contracts/collection-types.ts +++ b/packages/core/content-manager/shared/contracts/collection-types.ts @@ -132,7 +132,7 @@ export declare namespace Clone { } /** - * POST /collection-types/:model/:id + * PUT /collection-types/:model/:id */ export declare namespace Update { export interface Request { diff --git a/packages/core/content-releases/server/src/bootstrap.ts b/packages/core/content-releases/server/src/bootstrap.ts index 1e0b46105e..32d66de5a9 100644 --- a/packages/core/content-releases/server/src/bootstrap.ts +++ b/packages/core/content-releases/server/src/bootstrap.ts @@ -16,7 +16,6 @@ export const bootstrap = async ({ strapi }: { strapi: Core.LoadedStrapi }) => { async afterDelete(event) { try { - // @ts-expect-error TODO: lifecycles types looks like are not 100% finished const { model, result } = event; // @ts-expect-error TODO: lifecycles types looks like are not 100% finished if (model.kind === 'collectionType' && model.options?.draftAndPublish) { @@ -107,7 +106,6 @@ export const bootstrap = async ({ strapi }: { strapi: Core.LoadedStrapi }) => { async afterUpdate(event) { try { - // @ts-expect-error TODO: lifecycles types looks like are not 100% finished const { model, result } = event; // @ts-expect-error TODO: lifecycles types looks like are not 100% finished if (model.kind === 'collectionType' && model.options?.draftAndPublish) { diff --git a/packages/core/core/src/services/document-service/index.ts b/packages/core/core/src/services/document-service/index.ts index 0fe73b1128..e1e40b3c19 100644 --- a/packages/core/core/src/services/document-service/index.ts +++ b/packages/core/core/src/services/document-service/index.ts @@ -2,6 +2,7 @@ import type { Core, Modules } from '@strapi/types'; import { createMiddlewareManager, databaseErrorsMiddleware } from './middlewares'; import { createContentTypeRepository } from './repository'; +import { transformData } from './transform/data'; /** * Repository to : @@ -40,6 +41,9 @@ export const createDocumentService = (strapi: Core.Strapi): Modules.Documents.Se } as Modules.Documents.Service; return Object.assign(factory, { + utils: { + transformData, + }, use: middlewares.use.bind(middlewares), }); }; diff --git a/packages/core/core/src/services/document-service/repository.ts b/packages/core/core/src/services/document-service/repository.ts index 1888d695a0..48e5b4448d 100644 --- a/packages/core/core/src/services/document-service/repository.ts +++ b/packages/core/core/src/services/document-service/repository.ts @@ -376,5 +376,15 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => { publish: hasDraftAndPublish ? wrapInTransaction(publish) : (undefined as any), unpublish: hasDraftAndPublish ? wrapInTransaction(unpublish) : (undefined as any), discardDraft: hasDraftAndPublish ? wrapInTransaction(discardDraft) : (undefined as any), + /** + * @internal + * Exposed for use within document service middlewares + */ + updateComponents, + /** + * @internal + * Exposed for use within document service middlewares + */ + omitComponentData, }; }; diff --git a/packages/core/core/src/services/document-service/transform/__tests__/id-transform-i18n.test.ts b/packages/core/core/src/services/document-service/transform/__tests__/id-transform-i18n.test.ts index 6bb1ff9e7e..f109b9dd3a 100644 --- a/packages/core/core/src/services/document-service/transform/__tests__/id-transform-i18n.test.ts +++ b/packages/core/core/src/services/document-service/transform/__tests__/id-transform-i18n.test.ts @@ -179,9 +179,9 @@ describe('Transform relational data', () => { }); }); - it('Prevent connecting to invalid locales ', async () => { + it("Connect to source locale if the locale of the relation doesn't match", async () => { // Should not be able to connect to different locales than the current one - const promise = transformParamsDocumentId(CATEGORY_UID, { + const { data } = await transformParamsDocumentId(CATEGORY_UID, { data: { // Connect to another locale than the current one relatedCategories: [{ documentId: 'category-1', locale: 'fr' }], @@ -190,7 +190,11 @@ describe('Transform relational data', () => { status: 'draft', }); - expect(promise).rejects.toThrowError(); + expect(data).toMatchObject({ + relatedCategories: { + set: [{ id: 'category-1-en-draft' }], + }, + }); }); }); }); diff --git a/packages/core/core/src/services/document-service/transform/relations/utils/i18n.ts b/packages/core/core/src/services/document-service/transform/relations/utils/i18n.ts index 116bd1ac7c..d4643cf583 100644 --- a/packages/core/core/src/services/document-service/transform/relations/utils/i18n.ts +++ b/packages/core/core/src/services/document-service/transform/relations/utils/i18n.ts @@ -1,5 +1,4 @@ import { UID } from '@strapi/types'; -import { errors } from '@strapi/utils'; import { LongHandDocument } from './types'; export const isLocalizedContentType = (uid: UID.Schema) => { @@ -24,14 +23,9 @@ export const getRelationTargetLocale = ( const isTargetLocalized = isLocalizedContentType(opts.targetUid); const isSourceLocalized = isLocalizedContentType(opts.sourceUid); - // Locale validations + // Both source and target locales should match if (isSourceLocalized && isTargetLocalized) { - // Check the targetLocale matches - if (targetLocale !== opts.sourceLocale) { - throw new errors.ValidationError( - `Relation locale does not match the source locale ${JSON.stringify(relation)}` - ); - } + return opts.sourceLocale; } if (isTargetLocalized) { diff --git a/packages/core/core/src/services/entity-service/components.ts b/packages/core/core/src/services/entity-service/components.ts index 44249e0379..1133700f50 100644 --- a/packages/core/core/src/services/entity-service/components.ts +++ b/packages/core/core/src/services/entity-service/components.ts @@ -225,11 +225,7 @@ const updateComponents = async < }, }; } - - continue; - } - - if (attribute.type === 'dynamiczone') { + } else if (attribute.type === 'dynamiczone') { const dynamiczoneValues = data[attributeName as keyof TData] as DynamicZoneValue; await deleteOldDZComponents(uid, entityToUpdate, attributeName, dynamiczoneValues); @@ -254,8 +250,6 @@ const updateComponents = async < }, { concurrency: isDialectMySQL() && !strapi.db?.inTransaction() ? 1 : Infinity } ); - - continue; } } diff --git a/packages/core/database/src/lifecycles/types.ts b/packages/core/database/src/lifecycles/types.ts index cf97c8baa2..9caae5b92b 100644 --- a/packages/core/database/src/lifecycles/types.ts +++ b/packages/core/database/src/lifecycles/types.ts @@ -37,6 +37,7 @@ export interface Event { model: Meta; params: Params; state: Record; + result?: any; } export type SubscriberFn = (event: Event) => Promise | void; diff --git a/packages/core/types/src/modules/documents/index.ts b/packages/core/types/src/modules/documents/index.ts index 95fc31f913..5cdde64ab8 100644 --- a/packages/core/types/src/modules/documents/index.ts +++ b/packages/core/types/src/modules/documents/index.ts @@ -1,6 +1,7 @@ import type { UID } from '../..'; import type * as Middleware from './middleware'; import type { ServiceInstance } from './service-instance'; +import type { AnyDocument } from './result'; export * as Middleware from './middleware'; export * as Params from './params'; @@ -10,9 +11,13 @@ export * from './service-instance'; export type ID = string; +type ServiceUtils = { + transformData: (data: any, opts: any) => Promise; +}; + export type Service = { (uid: TContentTypeUID): ServiceInstance; - + utils: ServiceUtils; /** Add a middleware for all uid's and a specific action * @example - Add a default locale * strapi.documents.use('findMany', (ctx, next) => { diff --git a/packages/core/types/src/modules/documents/service-instance.ts b/packages/core/types/src/modules/documents/service-instance.ts index 1cac86a9b0..5cc3f2735b 100644 --- a/packages/core/types/src/modules/documents/service-instance.ts +++ b/packages/core/types/src/modules/documents/service-instance.ts @@ -1,9 +1,23 @@ -import type { UID, Utils } from '../..'; +import type { Utils, Schema } from '../..'; +import type * as EntityService from '../entity-service'; + +import type * as AttributeUtils from './params/attributes'; +import type * as UID from '../../uid'; + import type { ID } from '.'; import type { IsDraftAndPublishEnabled } from './draft-and-publish'; import type * as Params from './params/document-engine'; import type * as Result from './result/document-engine'; +// TODO: move to common place +type ComponentBody = { + [key: string]: AttributeUtils.GetValue< + | Schema.Attribute.Component + | Schema.Attribute.Component + | Schema.Attribute.DynamicZone + >; +}; + export type ServiceInstance = { findMany: >( params?: TParams @@ -73,4 +87,25 @@ export type ServiceInstance Result.DiscardDraft, undefined >; + + /** + * @internal + * Exposed for use within document service middlewares + */ + updateComponents: ( + uid: UID.Schema, + entityToUpdate: { + id: EntityService.Params.Attribute.ID; + }, + data: EntityService.Params.Data.Input + ) => Promise; + + /** + * @internal + * Exposed for use within document service middlewares + */ + omitComponentData: ( + contentType: Schema.ContentType, + data: EntityService.Params.Data.Input + ) => Partial>; }; diff --git a/packages/core/utils/src/content-types.ts b/packages/core/utils/src/content-types.ts index 8be2d4d0f9..a24effe62d 100644 --- a/packages/core/utils/src/content-types.ts +++ b/packages/core/utils/src/content-types.ts @@ -181,6 +181,17 @@ const getScalarAttributes = (schema: Model) => { ); }; +const getRelationalAttributes = (schema: Model) => { + return _.reduce( + schema.attributes, + (acc, attr, attrName) => { + if (isRelationalAttribute(attr)) acc.push(attrName); + return acc; + }, + [] as string[] + ); +}; + /** * Checks if an attribute is of type `type` * @param {object} attribute @@ -215,6 +226,7 @@ export { getNonWritableAttributes, getComponentAttributes, getScalarAttributes, + getRelationalAttributes, getWritableAttributes, isWritableAttribute, getNonVisibleAttributes, diff --git a/packages/plugins/i18n/admin/src/contentManagerHooks/editView.tsx b/packages/plugins/i18n/admin/src/contentManagerHooks/editView.tsx new file mode 100644 index 0000000000..0035b253d7 --- /dev/null +++ b/packages/plugins/i18n/admin/src/contentManagerHooks/editView.tsx @@ -0,0 +1,124 @@ +/* eslint-disable check-file/filename-naming-convention */ +import * as React from 'react'; + +import { Flex, VisuallyHidden } from '@strapi/design-system'; +import { Earth, EarthStriked } from '@strapi/icons'; +import { MessageDescriptor, useIntl } from 'react-intl'; +import styled from 'styled-components'; + +import { getTranslation } from '../utils/getTranslation'; + +import type { EditFieldLayout, EditLayout } from '@strapi/plugin-content-manager/strapi-admin'; + +interface MutateEditViewArgs { + layout: EditLayout; +} + +const mutateEditViewHook = ({ layout }: MutateEditViewArgs): MutateEditViewArgs => { + if ( + 'i18n' in layout.options && + typeof layout.options.i18n === 'object' && + layout.options.i18n !== null && + 'localized' in layout.options.i18n && + !layout.options.i18n.localized + ) { + return { layout }; + } + + const components = Object.entries(layout.components).reduce( + (acc, [key, componentLayout]) => { + return { + ...acc, + [key]: { + ...componentLayout, + layout: componentLayout.layout.map((row) => row.map(addLabelActionToField)), + }, + }; + }, + {} + ); + + return { + layout: { + ...layout, + components, + layout: layout.layout.map((panel) => panel.map((row) => row.map(addLabelActionToField))), + }, + } satisfies Pick; +}; + +const addLabelActionToField = (field: EditFieldLayout) => { + const isFieldLocalized = doesFieldHaveI18nPluginOpt(field.attribute.pluginOptions) + ? field.attribute.pluginOptions.i18n.localized + : true || ['uid', 'relation'].includes(field.attribute.type); + + const labelActionProps = { + title: { + id: isFieldLocalized + ? getTranslation('Field.localized') + : getTranslation('Field.not-localized'), + defaultMessage: isFieldLocalized + ? 'This value is unique for the selected locale' + : 'This value is the same across all locales', + }, + icon: isFieldLocalized ? : , + }; + + return { + ...field, + labelAction: , + }; +}; + +const doesFieldHaveI18nPluginOpt = ( + pluginOpts?: object +): pluginOpts is { i18n: { localized: boolean } } => { + if (!pluginOpts) { + return false; + } + + return ( + 'i18n' in pluginOpts && + typeof pluginOpts.i18n === 'object' && + pluginOpts.i18n !== null && + 'localized' in pluginOpts.i18n + ); +}; + +/* ------------------------------------------------------------------------------------------------- + * LabelAction + * -----------------------------------------------------------------------------------------------*/ + +interface LabelActionProps { + title: MessageDescriptor; + icon: React.ReactNode; +} + +const LabelAction = ({ title, icon }: LabelActionProps) => { + const { formatMessage } = useIntl(); + + return ( + + {`(${formatMessage(title)})`} + {React.cloneElement(icon as React.ReactElement, { + 'aria-hidden': true, + focusable: false, // See: https://allyjs.io/tutorials/focusing-in-svg.html#making-svg-elements-focusable + })} + + ); +}; + +const Span = styled(Flex)` + svg { + width: 12px; + height: 12px; + + fill: ${({ theme }) => theme.colors.neutral500}; + + path { + fill: ${({ theme }) => theme.colors.neutral500}; + } + } +`; + +export { mutateEditViewHook }; diff --git a/packages/plugins/i18n/admin/src/index.ts b/packages/plugins/i18n/admin/src/index.ts index e568eb7b34..c041daa4cc 100644 --- a/packages/plugins/i18n/admin/src/index.ts +++ b/packages/plugins/i18n/admin/src/index.ts @@ -11,6 +11,7 @@ import { import { Initializer } from './components/Initializer'; import { LocalePicker } from './components/LocalePicker'; import { PERMISSIONS } from './constants'; +import { mutateEditViewHook } from './contentManagerHooks/editView'; import { addColumnToTableHook } from './contentManagerHooks/listView'; import { extendCTBAttributeInitialDataMiddleware } from './middlewares/extendCTBAttributeInitialData'; import { extendCTBInitialDataMiddleware } from './middlewares/extendCTBInitialData'; @@ -46,6 +47,7 @@ export default { bootstrap(app: any) { // // Hook that adds a column into the CM's LV table app.registerHook('Admin/CM/pages/ListView/inject-column-in-table', addColumnToTableHook); + app.registerHook('Admin/CM/pages/EditView/mutate-edit-view-layout', mutateEditViewHook); // Add the settings link app.addSettingsLink('global', { diff --git a/packages/plugins/i18n/package.json b/packages/plugins/i18n/package.json index 2000a00a27..262bc4b5ed 100644 --- a/packages/plugins/i18n/package.json +++ b/packages/plugins/i18n/package.json @@ -68,6 +68,7 @@ "@strapi/admin-test-utils": "5.0.0-beta.1", "@strapi/pack-up": "5.0.0-beta.1", "@strapi/plugin-content-manager": "5.0.0-beta.1", + "@strapi/strapi": "5.0.0-beta.1", "@strapi/types": "5.0.0-beta.1", "@testing-library/react": "14.0.0", "@testing-library/user-event": "14.4.3", diff --git a/packages/plugins/i18n/server/src/bootstrap.ts b/packages/plugins/i18n/server/src/bootstrap.ts index ac659a9f5f..e4cd6e6243 100644 --- a/packages/plugins/i18n/server/src/bootstrap.ts +++ b/packages/plugins/i18n/server/src/bootstrap.ts @@ -1,5 +1,4 @@ -import type { Core } from '@strapi/types'; - +import type { Schema, Core } from '@strapi/types'; import { getService } from './utils'; const registerModelsHooks = () => { @@ -14,6 +13,49 @@ const registerModelsHooks = () => { await getService('permissions').actions.syncSuperAdminPermissionsWithLocales(); }, }); + + strapi.documents.use(async (context, next) => { + // @ts-expect-error ContentType is not typed correctly on the context + const schema: Schema.ContentType = context.contentType; + + if (!['create', 'update', 'discardDraft', 'publish'].includes(context.action)) { + return next(context); + } + + if (!getService('content-types').isLocalizedContentType(schema)) { + return next(context); + } + + // Build a populate array for all non localized fields within the schema + const { getNestedPopulateOfNonLocalizedAttributes } = getService('content-types'); + + const attributesToPopulate = getNestedPopulateOfNonLocalizedAttributes(schema.uid); + + // Get the result of the document service action + const result = (await next(context)) as any; + + // We may not have received a result with everything populated that we need + // Use the id and populate built from non localized fields to get the full + // result + let resultID; + if (Array.isArray(result?.versions)) { + resultID = result.versions[0].id; + } else if (result?.id) { + resultID = result.id; + } else { + return result; + } + + if (attributesToPopulate.length > 0) { + const populatedResult = await strapi.db + .query(schema.uid) + .findOne({ where: { id: resultID }, populate: attributesToPopulate }); + + await getService('localizations').syncNonLocalizedAttributes(populatedResult, schema); + } + + return result; + }); }; export default async ({ strapi }: { strapi: Core.Strapi }) => { diff --git a/packages/plugins/i18n/server/src/controllers/__tests__/content-types.test.ts b/packages/plugins/i18n/server/src/controllers/__tests__/content-types.test.ts index 55741600e6..7b013bd0a1 100644 --- a/packages/plugins/i18n/server/src/controllers/__tests__/content-types.test.ts +++ b/packages/plugins/i18n/server/src/controllers/__tests__/content-types.test.ts @@ -14,7 +14,18 @@ describe('i18n - Controller - content-types', () => { global.strapi = { contentType, getModel, - plugins: { i18n: { services: { 'content-types': ctService } } }, + plugins: { + i18n: { services: { 'content-types': ctService } }, + 'content-manager': { + services: { + 'document-metadata': { + getMetadata: () => ({ + availableLocales: [{ id: 2, locale: 'it', publishedAt: null }], + }), + }, + }, + }, + }, admin: { services: { constants: { default: { READ_ACTION: 'read', CREATE_ACTION: 'create' } } }, }, @@ -42,7 +53,7 @@ describe('i18n - Controller - content-types', () => { await controller.getNonLocalizedAttributes(ctx); } catch (e: any) { expect(e instanceof ApplicationError).toBe(true); - expect(e.message).toEqual('model.not.localized'); + expect(e.message).toEqual('Model api::country.country is not localized'); } }); diff --git a/packages/plugins/i18n/server/src/controllers/content-types.ts b/packages/plugins/i18n/server/src/controllers/content-types.ts index a36b5e1467..8cb01358f9 100644 --- a/packages/plugins/i18n/server/src/controllers/content-types.ts +++ b/packages/plugins/i18n/server/src/controllers/content-types.ts @@ -35,14 +35,14 @@ const controller = { const attributesToPopulate = getNestedPopulateOfNonLocalizedAttributes(model); if (!isLocalizedContentType(modelDef)) { - throw new ApplicationError('model.not.localized'); + throw new ApplicationError(`Model ${model} is not localized`); } const params = modelDef.kind === 'singleType' ? {} : { id }; const entity = await strapi.db .query(model) - .findOne({ where: params, populate: [...attributesToPopulate, 'localizations'] }); + .findOne({ where: params, populate: attributesToPopulate }); if (!entity) { return ctx.notFound(); @@ -67,9 +67,19 @@ const controller = { const nonLocalizedFields = copyNonLocalizedAttributes(modelDef, entity); const sanitizedNonLocalizedFields = pick(permittedFields, nonLocalizedFields); + const availableLocalesResult = await strapi.plugins['content-manager'] + .service('document-metadata') + .getMetadata(model, entity, { + availableLocales: true, + }); + + const availableLocales = availableLocalesResult.availableLocales.map((localeResult: any) => + pick(['id', 'locale', PUBLISHED_AT_ATTRIBUTE], localeResult) + ); + ctx.body = { nonLocalizedFields: sanitizedNonLocalizedFields, - localizations: entity.localizations.concat( + localizations: availableLocales.concat( pick(['id', 'locale', PUBLISHED_AT_ATTRIBUTE], entity) ), }; diff --git a/packages/plugins/i18n/server/src/services/__tests__/entity-service-decorator.test.ts b/packages/plugins/i18n/server/src/services/__tests__/entity-service-decorator.test.ts index 902aa72ebf..fc605ee043 100644 --- a/packages/plugins/i18n/server/src/services/__tests__/entity-service-decorator.test.ts +++ b/packages/plugins/i18n/server/src/services/__tests__/entity-service-decorator.test.ts @@ -198,102 +198,6 @@ describe('Entity service decorator', () => { }); }); - describe('create', () => { - test('Calls original create', async () => { - const entry = { - id: 1, - }; - - const defaultService = { - create: jest.fn(() => Promise.resolve(entry)), - }; - - const service = decorator(defaultService); - - const input = { data: { title: 'title ' } }; - await service.create('test-model', input); - - expect(defaultService.create).toHaveBeenCalledWith('test-model', input); - }); - - test('Skip processing if model is not localized', async () => { - const entry = { - id: 1, - localizations: [{ id: 2 }], - }; - - const defaultService = { - create: jest.fn(() => Promise.resolve(entry)), - }; - - const service = decorator(defaultService); - - const input = { data: { title: 'title ' } }; - const output = await service.create('non-localized-model', input); - - expect(defaultService.create).toHaveBeenCalledWith('non-localized-model', input); - expect(output).toStrictEqual(entry); - }); - }); - - describe('update', () => { - test('Calls original update', async () => { - const entry = { - id: 1, - }; - - const defaultService = { - update: jest.fn(() => Promise.resolve(entry)), - }; - - const service = decorator(defaultService); - - const input = { data: { title: 'title ' } }; - await service.update('test-model', 1, input); - - expect(defaultService.update).toHaveBeenCalledWith('test-model', 1, input); - }); - - test('Calls syncNonLocalizedAttributes if model is localized', async () => { - const entry = { - id: 1, - localizations: [{ id: 2 }], - }; - - const defaultService = { - update: jest.fn(() => Promise.resolve(entry)), - }; - - const service = decorator(defaultService); - - const input = { data: { title: 'title ' } }; - const output = await service.update('test-model', 1, input); - - expect(defaultService.update).toHaveBeenCalledWith('test-model', 1, input); - expect(syncNonLocalizedAttributes).toHaveBeenCalledWith(entry, { model }); - expect(output).toStrictEqual(entry); - }); - - test('Skip processing if model is not localized', async () => { - const entry = { - id: 1, - localizations: [{ id: 2 }], - }; - - const defaultService = { - update: jest.fn(() => Promise.resolve(entry)), - }; - - const service = decorator(defaultService); - - const input = { data: { title: 'title ' } }; - await service.update('non-localized-model', 1, input); - - expect(defaultService.update).toHaveBeenCalledWith('non-localized-model', 1, input); - expect(syncNonLocalizedAttributes).not.toHaveBeenCalled(); - }); - }); - describe('findMany', () => { test('Calls original findMany for non localized content type', async () => { const entry = { diff --git a/packages/plugins/i18n/server/src/services/__tests__/localizations.test.ts b/packages/plugins/i18n/server/src/services/__tests__/localizations.test.ts index e24a07015a..56d5edf262 100644 --- a/packages/plugins/i18n/server/src/services/__tests__/localizations.test.ts +++ b/packages/plugins/i18n/server/src/services/__tests__/localizations.test.ts @@ -55,89 +55,69 @@ const allLocalizedModel = { }, }; -const setGlobalStrapi = () => { - global.strapi = { - plugins: { - i18n: { - services: { - locales, - 'content-types': contentTypes, - }, +global.strapi = { + plugins: { + i18n: { + services: { + locales, + 'content-types': contentTypes, }, }, - db: { - dialect: { - client: 'sqlite', - }, + }, + db: { + dialect: { + client: 'sqlite', }, - } as any; + }, + documents: Object.assign( + () => ({ + updateComponents: jest.fn(), + omitComponentData: jest.fn(() => ({})), + }), + { + utils: { + transformData: jest.fn(async () => ({})), + }, + } + ), +} as any; + +const findMany = jest.fn(() => [{ id: 1, locale: 'fr' }]); +const update = jest.fn(); +global.strapi.db.query = () => { + return { findMany, update } as any; }; +const defaultLocale = 'en'; describe('localizations service', () => { describe('syncNonLocalizedAttributes', () => { test('Does nothing if no localizations set', async () => { - setGlobalStrapi(); - - const update = jest.fn(); - global.strapi.query = () => { - return { update } as any; - }; - const entry = { id: 1, locale: 'test' }; - await syncNonLocalizedAttributes(entry, { model }); + await syncNonLocalizedAttributes(entry, model); + + expect(findMany).not.toHaveBeenCalled(); + }); + + test('Does not update if all the fields are localized', async () => { + const entry = { id: 1, documentId: 'Doc1', locale: defaultLocale, title: 'test', stars: 100 }; + + await syncNonLocalizedAttributes(entry, allLocalizedModel); expect(update).not.toHaveBeenCalled(); }); test('Does not update the current locale', async () => { - setGlobalStrapi(); + const entry = { id: 1, documentId: 'Doc1', stars: 10, locale: defaultLocale }; - const update = jest.fn(); - global.strapi.query = () => { - return { update } as any; - }; + await syncNonLocalizedAttributes(entry, model); - const entry = { id: 1, locale: 'test', localizations: [] }; - - await syncNonLocalizedAttributes(entry, { model }); - - expect(update).not.toHaveBeenCalled(); - }); - - test('Does not update if all the fields are localized', async () => { - setGlobalStrapi(); - - const update = jest.fn(); - global.strapi.query = () => { - return { update } as any; - }; - - const entry = { id: 1, locale: 'test', localizations: [] }; - - await syncNonLocalizedAttributes(entry, { model: allLocalizedModel }); - - expect(update).not.toHaveBeenCalled(); - }); - - test('Updates locales with non localized fields only', async () => { - setGlobalStrapi(); - - const update = jest.fn(); - global.strapi.entityService = { update } as any; - - const entry = { - id: 1, - locale: 'test', - title: 'Localized', - stars: 1, - localizations: [{ id: 2, locale: 'fr' }], - }; - - await syncNonLocalizedAttributes(entry, { model }); - - expect(update).toHaveBeenCalledTimes(1); - expect(update).toHaveBeenCalledWith(model.uid, 2, { data: { stars: 1 } }); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + data: {}, + where: { documentId: 'Doc1', locale: { $eq: 'fr' }, publishedAt: null }, + }) + ); }); }); }); diff --git a/packages/plugins/i18n/server/src/services/content-types.ts b/packages/plugins/i18n/server/src/services/content-types.ts index 33bc749da8..74376820a5 100644 --- a/packages/plugins/i18n/server/src/services/content-types.ts +++ b/packages/plugins/i18n/server/src/services/content-types.ts @@ -1,10 +1,15 @@ import _ from 'lodash'; -import { pick, pipe, has, prop, isNil, cloneDeep, isArray, difference } from 'lodash/fp'; +import { pick, pipe, has, prop, isNil, cloneDeep, isArray } from 'lodash/fp'; import { errors, contentTypes as contentTypeUtils } from '@strapi/utils'; import { getService } from '../utils'; -const { isRelationalAttribute, getVisibleAttributes, isTypedAttribute, getScalarAttributes } = - contentTypeUtils; +const { + isRelationalAttribute, + getVisibleAttributes, + isTypedAttribute, + getScalarAttributes, + getRelationalAttributes, +} = contentTypeUtils; const { ApplicationError } = errors; const hasLocalizedOption = (modelOrAttribute: any) => { @@ -149,9 +154,19 @@ const getNestedPopulateOfNonLocalizedAttributes = (modelUID: any) => { const schema = strapi.getModel(modelUID); const scalarAttributes = getScalarAttributes(schema); const nonLocalizedAttributes = getNonLocalizedAttributes(schema); - const currentAttributesToPopulate = difference(nonLocalizedAttributes, scalarAttributes); - const attributesToPopulate = [...currentAttributesToPopulate]; + const allAttributes = [...scalarAttributes, ...nonLocalizedAttributes]; + if (schema.modelType === 'component') { + // When called recursively on a non localized component we + // need to explicitly populate that components relations + allAttributes.push(...getRelationalAttributes(schema)); + } + + const currentAttributesToPopulate = allAttributes.filter((value, index, self) => { + return self.indexOf(value) === index && self.lastIndexOf(value) === index; + }); + + const attributesToPopulate = [...currentAttributesToPopulate]; for (const attrName of currentAttributesToPopulate) { const attr = schema.attributes[attrName]; if (attr.type === 'component') { diff --git a/packages/plugins/i18n/server/src/services/entity-service-decorator.ts b/packages/plugins/i18n/server/src/services/entity-service-decorator.ts index b32e79487d..97316507a5 100644 --- a/packages/plugins/i18n/server/src/services/entity-service-decorator.ts +++ b/packages/plugins/i18n/server/src/services/entity-service-decorator.ts @@ -1,11 +1,8 @@ import { has, get, omit, isArray } from 'lodash/fp'; -import { errors } from '@strapi/utils'; import type { Schema } from '@strapi/types'; import { getService } from '../utils'; -const { ApplicationError } = errors; - const LOCALE_QUERY_FILTER = 'locale'; const SINGLE_ENTRY_ACTIONS = ['findOne', 'update', 'delete']; const BULK_ACTIONS = ['delete']; @@ -57,24 +54,6 @@ const wrapParams = async (params: any = {}, ctx: any = {}) => { }; }; -/** - * Assigns a valid locale or the default one if not define - * @param {object} data - */ -const assignValidLocale = async (data: any) => { - const { getValidLocale } = getService('content-types'); - - if (!data) { - return; - } - - try { - data.locale = await getValidLocale(data.locale); - } catch (e) { - throw new ApplicationError("This locale doesn't exist"); - } -}; - /** * Decorates the entity service with I18N business logic * @param {object} service - entity service @@ -110,57 +89,6 @@ const decorator = (service: any) => ({ return wrapParams(wrappedParams, ctx); }, - /** - * Creates an entry & make links between it and its related localizations - * @param {string} uid - Model uid - * @param {object} opts - Query options object (params, data, files, populate) - */ - async create(uid: any, opts: any = {}) { - const model = strapi.getModel(uid); - - const { syncNonLocalizedAttributes } = getService('localizations'); - const { isLocalizedContentType } = getService('content-types'); - - if (!isLocalizedContentType(model)) { - return service.create.call(this, uid, opts); - } - - const { data } = opts; - await assignValidLocale(data); - - const entry = await service.create.call(this, uid, opts); - - await syncNonLocalizedAttributes(entry, { model }); - return entry; - }, - - /** - * Updates an entry & update related localizations fields - * @param {string} uid - * @param {string} entityId - * @param {object} opts - Query options object (params, data, files, populate) - */ - async update(uid: any, entityId: any, opts: any = {}) { - const model = strapi.getModel(uid); - - const { syncNonLocalizedAttributes } = getService('localizations'); - const { isLocalizedContentType } = getService('content-types'); - - if (!isLocalizedContentType(model)) { - return service.update.call(this, uid, entityId, opts); - } - - const { data, ...restOptions } = opts; - - const entry = await service.update.call(this, uid, entityId, { - ...restOptions, - data: omit(['locale', 'localizations'], data), - }); - - await syncNonLocalizedAttributes(entry, { model }); - return entry; - }, - /** * Find an entry or several if fetching all locales * @param {string} uid - Model uid diff --git a/packages/plugins/i18n/server/src/services/localizations.ts b/packages/plugins/i18n/server/src/services/localizations.ts index 0c082dfd2f..be93f74592 100644 --- a/packages/plugins/i18n/server/src/services/localizations.ts +++ b/packages/plugins/i18n/server/src/services/localizations.ts @@ -1,40 +1,66 @@ -import { isEmpty } from 'lodash/fp'; +import { cloneDeep, isEmpty } from 'lodash/fp'; +import { type Schema } from '@strapi/types'; import { async } from '@strapi/utils'; import { getService } from '../utils'; -const isDialectMySQL = () => strapi.db.dialect.client === 'mysql'; - /** * Update non localized fields of all the related localizations of an entry with the entry values - * @param {Object} entry entry to update - * @param {Object} options - * @param {Object} options.model corresponding model */ -const syncNonLocalizedAttributes = async (entry: any, { model }: any) => { +const syncNonLocalizedAttributes = async (sourceEntry: any, model: Schema.ContentType) => { const { copyNonLocalizedAttributes } = getService('content-types'); - if (Array.isArray(entry?.localizations)) { - const nonLocalizedAttributes = copyNonLocalizedAttributes(model, entry); + const nonLocalizedAttributes = copyNonLocalizedAttributes(model, sourceEntry); + if (isEmpty(nonLocalizedAttributes)) { + return; + } - if (isEmpty(nonLocalizedAttributes)) { - return; - } + const uid = model.uid; + const documentId = sourceEntry.documentId; + const locale = sourceEntry.locale; + const status = sourceEntry?.publishedAt ? 'published' : 'draft'; - const updateLocalization = (id: any) => { - return strapi.entityService.update(model.uid, id, { data: nonLocalizedAttributes }); - }; + // Find all the entries that need to be updated + // this is every other entry of the document in the same status but a different locale + const localeEntriesToUpdate = await strapi.db.query(uid).findMany({ + where: { + documentId, + publishedAt: status === 'published' ? { $ne: null } : null, + locale: { $ne: locale }, + }, + select: ['locale', 'id'], + }); - // MySQL/MariaDB can cause deadlocks here if concurrency higher than 1 - // TODO: use a transaction to avoid deadlocks - await async.map( - entry.localizations, - (localization: any) => updateLocalization(localization.id), + const entryData = await strapi.documents(uid).omitComponentData(model, nonLocalizedAttributes); + + await async.map(localeEntriesToUpdate, async (entry: any) => { + const transformedData = await strapi.documents.utils.transformData( + cloneDeep(nonLocalizedAttributes), { - concurrency: isDialectMySQL() && !strapi.db.inTransaction() ? 1 : Infinity, + uid, + status, + locale: entry.locale, + allowMissingId: true, } ); - } + + // Update or create non localized components for the entry + const componentData = await strapi + .documents(uid) + .updateComponents(uid, entry, transformedData as any); + + // Update every other locale entry of this documentId in the same status + await strapi.db.query(uid).update({ + where: { + documentId, + publishedAt: status === 'published' ? { $ne: null } : null, + locale: { $eq: entry.locale }, + }, + // The data we send to the update function is the entry data merged with + // the updated component data + data: Object.assign(cloneDeep(entryData), componentData), + }); + }); }; const localizations = () => ({ diff --git a/yarn.lock b/yarn.lock index 41dbd53bc1..0a316fcf0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7903,6 +7903,7 @@ __metadata: "@strapi/icons": "npm:1.16.0" "@strapi/pack-up": "npm:5.0.0-beta.1" "@strapi/plugin-content-manager": "npm:5.0.0-beta.1" + "@strapi/strapi": "npm:5.0.0-beta.1" "@strapi/types": "npm:5.0.0-beta.1" "@strapi/utils": "npm:5.0.0-beta.1" "@testing-library/react": "npm:14.0.0"