mirror of
https://github.com/strapi/strapi.git
synced 2025-12-03 18:42:47 +00:00
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:
parent
21128ddc87
commit
43b9e91c67
@ -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' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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']>>;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
124
packages/plugins/i18n/admin/src/contentManagerHooks/editView.tsx
Normal file
124
packages/plugins/i18n/admin/src/contentManagerHooks/editView.tsx
Normal 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 };
|
||||
@ -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', {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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)
|
||||
),
|
||||
};
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = () => ({
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user