feat(i18n): non localized fields (#19720)

* feat(i18n): wip non localized fields

* feat(i18n): use document service middleware for non localized fields

* feat(i18n): support component data in non localized sync

* chore(i18n): cleanup and fix unit tests

* fix(i18n): reintroduce permissions sync

* test(i18n): api test for non localized fields

* feat(i18n): add earth icon to symbolise localised fields (#19774)

* test(i18n): cover publish, unpublish, update and create in api tests

* feat(i18n): ensure non localized fields are populated

* fix(i18n): get right id

* fix(content-manager): doc metadata in non d&p cases

* fix(conent-manager): i18n detection

* fix: pr feedback

* fix(i18n): handle non localized components

* feat(i18n): sync non localized components

* fix(i18n): wip unit test

* feat(i18n): handle relations within non localized components

* feat(i18n): reintroduce FE and fix for repeatables

* chore: lockfile

* chore(i18n): cleanup

* chore(i18n): cleanup

* feat(i18n): match relation locales to source entry during transformation

* fix(i18n): unit tests

* fix(i18n): getNonLocalizedAttributes

* chore(i18n): fix unit tests

* chore(i18n): pr feedback

* chore(i18n): pr feedback

* fix(i18n): unit tests

---------

Co-authored-by: Josh <37798644+joshuaellis@users.noreply.github.com>
This commit is contained in:
Jamie Howard 2024-04-02 10:08:10 +01:00 committed by GitHub
parent 21128ddc87
commit 43b9e91c67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1178 additions and 521 deletions

View File

@ -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' },
],
});
});
});

View File

@ -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,
})
);
}
}
);
}
});
});
});
});
});
});

View File

@ -8,18 +8,19 @@ import { BlocksEditor } from './BlocksEditor';
import type { Schema } from '@strapi/types';
interface BlocksInputProps extends Omit<InputProps, 'type'> {
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 (
<Field id={id} name={name} hint={hint} error={field.error} required={required}>
<Flex direction="column" alignItems="stretch" gap={1}>
<FieldLabel>{label}</FieldLabel>
<FieldLabel action={labelAction}>{label}</FieldLabel>
<BlocksEditor
name={name}
error={field.error}

View File

@ -1,3 +1,5 @@
import * as React from 'react';
import { InputProps, useField } from '@strapi/admin/strapi-admin';
import { Box, Flex, IconButton, Typography } from '@strapi/design-system';
import { Trash } from '@strapi/icons';
@ -15,7 +17,9 @@ import { RepeatableComponent } from './Repeatable';
interface ComponentInputProps
extends Omit<Extract<EditFieldLayout, { type: 'component' }>, 'size' | 'hint'>,
Pick<InputProps, 'hint'> {}
Pick<InputProps, 'hint'> {
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 && <Typography textColor="danger600">*</Typography>}
</Typography>
{/* {labelAction && <LabelAction paddingLeft={1}>{labelAction}</LabelAction>} */}
{labelAction && <Box paddingLeft={1}>{labelAction}</Box>}
</Flex>
{showResetComponent && (

View File

@ -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<DynamicZoneContextVa
interface DynamicZoneProps
extends Omit<Extract<EditFieldLayout, { type: 'dynamiczone' }>, 'size' | 'hint'>,
Pick<InputProps, 'hint'> {}
Pick<InputProps, 'hint'>,
Pick<DynamicZoneLabelProps, 'labelAction'> {}
const DynamicZone = ({
attribute,
disabled,
hint,
label,
labelAction,
name,
required = false,
}: DynamicZoneProps) => {
@ -244,7 +246,7 @@ const DynamicZone = ({
<DynamicZoneLabel
hint={hint}
label={label}
// labelAction={labelAction}
labelAction={labelAction}
name={name}
numberOfComponents={dynamicDisplayedComponentsLength}
required={required}

View File

@ -371,16 +371,15 @@ interface RelationsInputProps extends Omit<RelationsFieldProps, 'type'> {
* 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<string | undefined>('');
const [searchParams, setSearchParams] = React.useState({
@ -490,13 +489,9 @@ const RelationsInput = ({
return (
<Combobox
ref={fieldRef}
name={name}
autocomplete="none"
error={field.error}
name={name}
hint={hint}
required={required}
label={label}
disabled={disabled}
placeholder={
placeholder ||
formatMessage({
@ -528,6 +523,7 @@ const RelationsInput = ({
onInputChange={(event) => {
handleSearch(event.currentTarget.value);
}}
{...props}
>
{options.map((opt) => {
const textValue = getRelationLabel(opt, mainField);

View File

@ -36,230 +36,224 @@ interface UIDInputProps extends Omit<InputProps, 'type'> {
type: Schema.Attribute.TypeOf<Schema.Attribute.UID>;
}
const UIDInput = React.forwardRef<any, UIDInputProps>(
({ hint, disabled, label, name, placeholder, required }, ref) => {
const { model, id } = useDoc();
const allFormValues = useForm('InputUID', (form) => form.values);
const [availability, setAvailability] = React.useState<CheckUIDAvailability.Response>();
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<any, UIDInputProps>((props, ref) => {
const { model, id } = useDoc();
const allFormValues = useForm('InputUID', (form) => form.values);
const [availability, setAvailability] = React.useState<CheckUIDAvailability.Response>();
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.
<TextInput
ref={composedRefs}
disabled={props.disabled}
error={field.error}
endAction={
<Flex position="relative" gap={1}>
{availability && !showRegenerate && (
<TextValidation
alignItems="center"
gap={1}
justifyContent="flex-end"
available={!!availability?.isAvailable}
data-not-here-outer
position="absolute"
pointerEvents="none"
right={6}
width="100px"
>
{availability?.isAvailable ? <CheckCircle /> : <ExclamationMarkCircle />}
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 (
<TextInput
ref={composedRefs}
disabled={disabled}
error={field.error}
endAction={
<Flex position="relative" gap={1}>
{availability && !showRegenerate && (
<TextValidation
alignItems="center"
gap={1}
justifyContent="flex-end"
available={!!availability?.isAvailable}
data-not-here-outer
position="absolute"
pointerEvents="none"
right={6}
width="100px"
<Typography
textColor={availability.isAvailable ? 'success600' : 'danger600'}
variant="pi"
>
{availability?.isAvailable ? <CheckCircle /> : <ExclamationMarkCircle />}
<Typography
textColor={availability.isAvailable ? 'success600' : 'danger600'}
variant="pi"
>
{formatMessage(
availability.isAvailable
? {
id: 'content-manager.components.uid.available',
defaultMessage: 'Available',
}
: {
id: 'content-manager.components.uid.unavailable',
defaultMessage: 'Unavailable',
}
)}
</Typography>
</TextValidation>
)}
{!disabled && (
<>
{showRegenerate && (
<TextValidation alignItems="center" justifyContent="flex-end" gap={1}>
<Typography textColor="primary600" variant="pi">
{formatMessage({
id: 'content-manager.components.uid.regenerate',
defaultMessage: 'Regenerate',
})}
</Typography>
</TextValidation>
{formatMessage(
availability.isAvailable
? {
id: 'content-manager.components.uid.available',
defaultMessage: 'Available',
}
: {
id: 'content-manager.components.uid.unavailable',
defaultMessage: 'Unavailable',
}
)}
</Typography>
</TextValidation>
)}
<FieldActionWrapper
onClick={handleRegenerateClick}
label={formatMessage({
id: 'content-manager.components.uid.regenerate',
defaultMessage: 'Regenerate',
})}
onMouseEnter={() => setShowRegenerate(true)}
onMouseLeave={() => setShowRegenerate(false)}
>
{isLoading ? (
<LoadingWrapper data-testid="loading-wrapper">
<Loader />
</LoadingWrapper>
) : (
<Refresh />
)}
</FieldActionWrapper>
</>
)}
</Flex>
}
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 && (
<TextValidation alignItems="center" justifyContent="flex-end" gap={1}>
<Typography textColor="primary600" variant="pi">
{formatMessage({
id: 'content-manager.components.uid.regenerate',
defaultMessage: 'Regenerate',
})}
</Typography>
</TextValidation>
)}
<FieldActionWrapper
onClick={handleRegenerateClick}
label={formatMessage({
id: 'content-manager.components.uid.regenerate',
defaultMessage: 'Regenerate',
})}
onMouseEnter={() => setShowRegenerate(true)}
onMouseLeave={() => setShowRegenerate(false)}
>
{isLoading ? (
<LoadingWrapper data-testid="loading-wrapper">
<Loader />
</LoadingWrapper>
) : (
<Refresh />
)}
</FieldActionWrapper>
</>
)}
</Flex>
}
onChange={field.onChange}
value={field.value ?? ''}
{...props}
/>
);
});
/* -------------------------------------------------------------------------------------------------
* FieldActionWrapper

View File

@ -26,7 +26,7 @@ interface WysiwygProps extends Omit<InputProps, 'type'> {
}
const Wysiwyg = React.forwardRef<EditorApi, WysiwygProps>(
({ hint, disabled, label, name, placeholder, required }, forwardedRef) => {
({ hint, disabled, label, name, placeholder, required, labelAction }, forwardedRef) => {
const field = useField(name);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const editorRef = React.useRef<EditorFromTextArea>(
@ -105,7 +105,7 @@ const Wysiwyg = React.forwardRef<EditorApi, WysiwygProps>(
return (
<Field name={name} hint={hint} error={field.error} required={required}>
<Flex direction="column" alignItems="stretch" gap={1}>
<FieldLabel>{label}</FieldLabel>
<FieldLabel action={labelAction}>{label}</FieldLabel>
<EditorLayout
isExpandMode={isExpandMode}
error={field.error}

View File

@ -95,7 +95,8 @@ const createHistoryService = ({ strapi }: { strapi: Core.LoadedStrapi }) => {
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

View File

@ -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

View File

@ -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 {

View File

@ -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) {

View File

@ -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),
});
};

View File

@ -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,
};
};

View File

@ -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' }],
},
});
});
});
});

View File

@ -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) {

View File

@ -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;
}
}

View File

@ -37,6 +37,7 @@ export interface Event {
model: Meta;
params: Params;
state: Record<string, unknown>;
result?: any;
}
export type SubscriberFn = (event: Event) => Promise<void> | void;

View File

@ -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<AnyDocument>;
};
export type Service = {
<TContentTypeUID extends UID.ContentType>(uid: TContentTypeUID): ServiceInstance<TContentTypeUID>;
utils: ServiceUtils;
/** Add a middleware for all uid's and a specific action
* @example - Add a default locale
* strapi.documents.use('findMany', (ctx, next) => {

View File

@ -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<UID.Component, false>
| Schema.Attribute.Component<UID.Component, true>
| Schema.Attribute.DynamicZone
>;
};
export type ServiceInstance<TContentTypeUID extends UID.ContentType = UID.ContentType> = {
findMany: <TParams extends Params.FindMany<TContentTypeUID>>(
params?: TParams
@ -73,4 +87,25 @@ export type ServiceInstance<TContentTypeUID extends UID.ContentType = UID.Conten
) => Result.DiscardDraft<TContentTypeUID, TParams>,
undefined
>;
/**
* @internal
* Exposed for use within document service middlewares
*/
updateComponents: (
uid: UID.Schema,
entityToUpdate: {
id: EntityService.Params.Attribute.ID;
},
data: EntityService.Params.Data.Input<UID.Schema>
) => Promise<ComponentBody>;
/**
* @internal
* Exposed for use within document service middlewares
*/
omitComponentData: (
contentType: Schema.ContentType,
data: EntityService.Params.Data.Input<Schema.ContentType['uid']>
) => Partial<EntityService.Params.Data.Input<Schema.ContentType['uid']>>;
};

View File

@ -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,

View File

@ -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<EditLayout['components']>(
(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<MutateEditViewArgs, 'layout'>;
};
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 ? <Earth /> : <EarthStriked />,
};
return {
...field,
labelAction: <LabelAction {...labelActionProps} />,
};
};
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 (
<Span as="span">
<VisuallyHidden as="span">{`(${formatMessage(title)})`}</VisuallyHidden>
{React.cloneElement(icon as React.ReactElement, {
'aria-hidden': true,
focusable: false, // See: https://allyjs.io/tutorials/focusing-in-svg.html#making-svg-elements-focusable
})}
</Span>
);
};
const Span = styled(Flex)`
svg {
width: 12px;
height: 12px;
fill: ${({ theme }) => theme.colors.neutral500};
path {
fill: ${({ theme }) => theme.colors.neutral500};
}
}
`;
export { mutateEditViewHook };

View File

@ -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', {

View File

@ -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",

View File

@ -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 }) => {

View File

@ -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');
}
});

View File

@ -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)
),
};

View File

@ -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 = {

View File

@ -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 },
})
);
});
});
});

View File

@ -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') {

View File

@ -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

View File

@ -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 = () => ({

View File

@ -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"