feat: add i18n to admin panel (#1326)

Co-authored-by: shikhantmaungs <shinkhantmaungs@gmail.com>
Co-authored-by: Thomas Ghysels <info@thomasg.be>
Co-authored-by: Kokutse Djoguenou <kokutse@Kokutses-MacBook-Pro.local>
Co-authored-by: Christian Gil <47041342+ChrisGV04@users.noreply.github.com>
Co-authored-by: Łukasz Rabiec <lukaszrabiec@gmail.com>
Co-authored-by: Jenny <jennifer.eberlei@gmail.com>
Co-authored-by: Hung Vu <hunghvu2017@gmail.com>
Co-authored-by: Shin Khant Maung <101539335+shinkhantmaungs@users.noreply.github.com>
Co-authored-by: Carlo Brualdi <carlo.brualdi@gmail.com>
Co-authored-by: Ariel Tonglet <ariel.tonglet@gmail.com>
Co-authored-by: Roman Ryzhikov <general+github@ya.ru>
Co-authored-by: maekoya <maekoya@stromatolite.jp>
Co-authored-by: Emilia Trollros <3m1l1a@emiliatrollros.se>
Co-authored-by: Kokutse J Djoguenou <90865585+Julesdj@users.noreply.github.com>
Co-authored-by: Mitch Dries <mitch.dries@gmail.com>

BREAKING CHANGE: If you assigned labels to collections, globals or block names, you need to update your config! Your GraphQL schema and generated Typescript interfaces may have changed. Payload no longer uses labels for code based naming. To prevent breaking changes to your GraphQL API and typescript types in your project, you can assign the below properties to match what Payload previously generated for you from labels.

On Collections
Use `graphQL.singularName`, `graphQL.pluralName` for GraphQL schema names.
Use `typescript.interface` for typescript generation name.

On Globals
Use `graphQL.name` for GraphQL Schema name.
Use `typescript.interface` for typescript generation name.

On Blocks (within Block fields)
Use `graphQL.singularName` for graphQL schema names.
This commit is contained in:
Dan Ribbens
2022-11-18 07:36:30 -05:00
committed by GitHub
parent c49ee15b6a
commit bab34d82f5
279 changed files with 9547 additions and 3242 deletions

View File

@@ -1,9 +1,11 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
const BeforeLogin: React.FC = () => {
const BeforeLogin: React.FC<{i18n}> = () => {
const { t } = useTranslation();
return (
<div>
<h3>Welcome</h3>
<h3>{t('general:welcome')}</h3>
<p>
This demo is a set up to configure Payload for the develop and testing of features. To see a product demo of a Payload project
please visit:

View File

@@ -51,6 +51,15 @@ export default buildConfig({
},
},
},
i18n: {
resources: {
en: {
general: {
dashboard: 'Home',
},
},
},
},
collections: [
{
slug: 'users',
@@ -59,13 +68,28 @@ export default buildConfig({
},
{
slug,
labels: {
singular: {
en: 'Post en',
es: 'Post es',
},
plural: {
en: 'Posts en',
es: 'Posts es',
},
},
admin: {
description: { en: 'Description en', es: 'Description es' },
listSearchableFields: ['title', 'description', 'number'],
group: 'One',
group: { en: 'One', es: 'Una' },
},
fields: [
{
name: 'title',
label: {
en: 'Title en',
es: 'Title es',
},
type: 'text',
},
{
@@ -81,7 +105,7 @@ export default buildConfig({
{
slug: 'group-one-collection-ones',
admin: {
group: 'One',
group: { en: 'One', es: 'Una' },
},
fields: [
{
@@ -93,7 +117,7 @@ export default buildConfig({
{
slug: 'group-one-collection-twos',
admin: {
group: 'One',
group: { en: 'One', es: 'Una' },
},
fields: [
{
@@ -130,6 +154,10 @@ export default buildConfig({
globals: [
{
slug: globalSlug,
label: {
en: 'Global en',
es: 'Global es',
},
admin: {
group: 'Group',
},

View File

@@ -158,7 +158,7 @@ describe('admin', () => {
await page.locator('#action-delete').click();
await page.locator('#confirm-delete').click();
await expect(page.locator(`text=Post "${id}" successfully deleted.`)).toBeVisible();
await expect(page.locator(`text=Post en "${id}" successfully deleted.`)).toBeVisible();
expect(page.url()).toContain(url.list);
});
@@ -173,6 +173,29 @@ describe('admin', () => {
});
});
describe('i18n', () => {
test('should allow changing language', async () => {
await page.goto(url.account);
const field = page.locator('.account__language .react-select');
await field.click({ delay: 100 });
const options = page.locator('.rs__option');
await options.locator('text=Español').click();
await expect(page.locator('.step-nav')).toContainText('Tablero');
await field.click({ delay: 100 });
await options.locator('text=English').click();
await field.click({ delay: 100 });
await expect(page.locator('.form-submit .btn')).toContainText('Save');
});
test('should allow custom translation', async () => {
await expect(page.locator('.step-nav')).toContainText('Home');
});
});
describe('list view', () => {
const tableRowLocator = 'table >> tbody >> tr';
@@ -318,6 +341,55 @@ describe('admin', () => {
expect(await page.locator('.row-2 .cell-id').innerText()).toEqual(secondId);
});
});
describe('i18n', () => {
test('should display translated collections and globals config options', async () => {
await page.goto(url.list);
// collection label
await expect(page.locator('#nav-posts')).toContainText('Posts en');
// global label
await expect(page.locator('#nav-global-global')).toContainText('Global en');
// view description
await expect(page.locator('.view-description')).toContainText('Description en');
});
test('should display translated field titles', async () => {
await createPost();
// column controls
await page.locator('.list-controls__toggle-columns').click();
await expect(await page.locator('.column-selector__column >> text=Title en')).toHaveText('Title en');
// filters
await page.locator('.list-controls__toggle-where').click();
await page.locator('.where-builder__add-first-filter').click();
await page.locator('.condition__field .rs__control').click();
const options = page.locator('.rs__option');
await expect(await options.locator('text=Title en')).toHaveText('Title en');
// list columns
await expect(await page.locator('#heading-title .sort-column__label')).toHaveText('Title en');
await expect(await page.locator('.search-filter input')).toHaveAttribute('placeholder', /(Title en)/);
});
test('should use fallback language on field titles', async () => {
// change language German
await page.goto(url.account);
const field = page.locator('.account__language .react-select');
await field.click({ delay: 100 });
const languageSelect = page.locator('.rs__option');
// text field does not have a 'de' label
await languageSelect.locator('text=Deutsch').click();
await page.goto(url.list);
await page.locator('.list-controls__toggle-columns').click();
// expecting the label to fall back to english as default fallbackLng
await expect(await page.locator('.column-selector__column >> text=Title en')).toHaveText('Title en');
});
});
});
});

View File

@@ -86,6 +86,13 @@ const MyConfig: Config = {
res.json({ message: 'Hello, world!' });
},
},
{
path: `/${applicationEndpoint}/i18n`,
method: 'get',
handler: (req: PayloadRequest, res: Response): void => {
res.json({ message: req.t('general:backToDashboard') });
},
},
{
path: `/${rootEndpoint}`,
method: 'get',
@@ -102,7 +109,7 @@ const MyConfig: Config = {
express.json({ type: 'application/json' }),
(req: PayloadRequest, res: Response): void => {
res.json(req.body);
}
},
],
},
],
@@ -115,6 +122,6 @@ const MyConfig: Config = {
},
});
},
}
};
export default buildConfig(MyConfig);

View File

@@ -54,6 +54,13 @@ describe('Endpoints', () => {
expect(status).toBe(200);
expect(params).toMatchObject(data);
});
it('should have i18n on req', async () => {
const { status, data } = await client.endpoint(`/api/${applicationEndpoint}/i18n`, 'get');
expect(status).toBe(200);
expect(data.message).toStrictEqual('Back to Dashboard');
});
});
describe('Root', () => {

View File

@@ -95,7 +95,7 @@ const ArrayFields: CollectionConfig = {
admin: {
description: 'Row labels rendered from a function.',
components: {
RowLabel: ({ data, fallback }) => data.title || fallback,
RowLabel: ({ data }) => data.title,
},
},
},

View File

@@ -29,6 +29,11 @@ export const blocksFieldSeedData = [
},
],
},
{
blockName: 'I18n Block',
blockType: 'i18n-text',
text: 'first block',
},
] as const;
export const blocksField: Field = {
@@ -154,6 +159,48 @@ const BlockFields: CollectionConfig = {
name: 'localizedBlocks',
localized: true,
},
{
type: 'blocks',
name: 'i18nBlocks',
label: {
en: 'Block en',
es: 'Block es',
},
labels: {
singular: {
en: 'Block en',
es: 'Block es',
},
plural: {
en: 'Blocks en',
es: 'Blocks es',
},
},
blocks: [
{
slug: 'text',
graphQL: {
singularName: 'I18nText',
},
labels: {
singular: {
en: 'Text en',
es: 'Text es',
},
plural: {
en: 'Texts en',
es: 'Texts es',
},
},
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
},
],
};

View File

@@ -55,6 +55,7 @@ const CollapsibleFields: CollectionConfig = {
type: 'text',
},
{
// TODO: change group name, to not be a duplicate of the above collapsible
name: 'group',
type: 'group',
fields: [

View File

@@ -0,0 +1,34 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
const RadioFields: CollectionConfig = {
slug: 'radio-fields',
fields: [
{
name: 'radio',
label: {
en: 'Radio en', es: 'Radio es',
},
type: 'radio',
options: [
{
label: { en: 'Value One', es: 'Value Uno' },
value: 'one',
},
{
label: 'Value Two',
value: 'two',
},
{
label: 'Value Three',
value: 'three',
},
],
},
],
};
export const radiosDoc = {
radio: 'one',
};
export default RadioFields;

View File

@@ -147,7 +147,7 @@ const TabsFields: CollectionConfig = {
},
{
name: 'localizedTab',
label: 'Localized Tab',
label: { en: 'Localized Tab en', es: 'Localized Tab es' },
localized: true,
description: 'This tab is localized and requires a name',
fields: [

View File

@@ -18,6 +18,24 @@ const TextFields: CollectionConfig = {
type: 'text',
localized: true,
},
{
name: 'i18nText',
type: 'text',
label: {
en: 'Text en',
es: 'Text es',
},
admin: {
placeholder: {
en: 'en placeholder',
es: 'es placeholder',
},
description: {
en: 'en description',
es: 'es description',
},
},
},
{
name: 'defaultFunction',
type: 'text',

View File

@@ -20,6 +20,7 @@ import IndexedFields from './collections/Indexed';
import NumberFields, { numberDoc } from './collections/Number';
import CodeFields, { codeDoc } from './collections/Code';
import RelationshipFields from './collections/Relationship';
import RadioFields, { radiosDoc } from './collections/Radio';
export default buildConfig({
admin: {
@@ -41,6 +42,7 @@ export default buildConfig({
CollapsibleFields,
ConditionalLogic,
DateFields,
RadioFields,
GroupFields,
IndexedFields,
NumberFields,
@@ -70,6 +72,7 @@ export default buildConfig({
await payload.create({ collection: 'conditional-logic', data: conditionalLogicDoc });
await payload.create({ collection: 'group-fields', data: groupDoc });
await payload.create({ collection: 'select-fields', data: selectsDoc });
await payload.create({ collection: 'radio-fields', data: radiosDoc });
await payload.create({ collection: 'tabs-fields', data: tabsDoc });
await payload.create({ collection: 'point-fields', data: pointDoc });
await payload.create({ collection: 'date-fields', data: dateDoc });

View File

@@ -28,13 +28,64 @@ describe('fields', () => {
});
describe('text', () => {
let url: AdminUrlUtil;
beforeAll(() => {
url = new AdminUrlUtil(serverURL, 'text-fields');
});
test('should display field in list view', async () => {
const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'text-fields');
await page.goto(url.list);
const textCell = page.locator('.row-1 .cell-text');
await expect(textCell)
.toHaveText(textDoc.text);
});
test('should display i18n label in cells when missing field data', async () => {
await page.goto(url.list);
const textCell = page.locator('.row-1 .cell-i18nText');
await expect(textCell)
.toHaveText('<No Text en>');
});
test('should show i18n label', async () => {
await page.goto(url.create);
await expect(page.locator('label[for="field-i18nText"]')).toHaveText('Text en');
});
test('should show i18n placeholder', async () => {
await page.goto(url.create);
await expect(await page.locator('#field-i18nText')).toHaveAttribute('placeholder', 'en placeholder');
});
test('should show i18n descriptions', async () => {
await page.goto(url.create);
const description = page.locator('.field-description-i18nText');
await expect(description).toHaveText('en description');
});
});
describe('radio', () => {
let url: AdminUrlUtil;
beforeAll(() => {
url = new AdminUrlUtil(serverURL, 'radio-fields');
});
test('should show i18n label in list', async () => {
await page.goto(url.list);
await expect(page.locator('.cell-radio')).toHaveText('Value One');
});
test('should show i18n label while editing', async () => {
await page.goto(url.create);
await expect(page.locator('label[for="field-radio"]')).toHaveText('Radio en');
});
test('should show i18n radio labels', async () => {
await page.goto(url.create);
await expect(await page.locator('label[for="field-radio-one"] .radio-input__label'))
.toHaveText('Value One');
});
});
describe('point', () => {
@@ -94,6 +145,28 @@ describe('fields', () => {
});
});
describe('blocks', () => {
let url: AdminUrlUtil;
beforeAll(() => {
url = new AdminUrlUtil(serverURL, 'block-fields');
});
test('should use i18n block labels', async () => {
await page.goto(url.create);
await expect(page.locator('#field-i18nBlocks .blocks-field__header')).toContainText('Block en');
const addButton = page.locator('#field-i18nBlocks .btn__label');
await expect(addButton).toContainText('Add Block en');
await addButton.click();
const blockSelector = page.locator('#field-i18nBlocks .block-selector .block-selection').first();
await expect(blockSelector).toContainText('Text en');
await blockSelector.click();
await expect(page.locator('#i18nBlocks-row-0 .blocks-field__block-pill-text')).toContainText('Text en');
});
});
describe('fields - array', () => {
let url: AdminUrlUtil;
beforeAll(() => {

View File

@@ -157,6 +157,24 @@ describe('Fields', () => {
expect(definitions.collapsibleTextUnique).toEqual(1);
expect(options.collapsibleTextUnique).toMatchObject({ unique: true });
});
it('should throw validation error saving on unique fields', async () => {
const data = {
text: 'a',
uniqueText: 'a',
};
await payload.create({
collection: 'indexed-fields',
data,
});
expect(async () => {
const result = await payload.create({
collection: 'indexed-fields',
data,
});
return result.error;
}).toBeDefined();
});
});
describe('point', () => {

View File

@@ -32,6 +32,14 @@ export interface ArrayField {
text?: string;
id?: string;
}[];
rowLabelAsFunction: {
title?: string;
id?: string;
}[];
rowLabelAsComponent: {
title?: string;
id?: string;
}[];
createdAt: string;
updatedAt: string;
}
@@ -170,6 +178,12 @@ export interface BlockField {
blockType: 'tabs';
}
)[];
i18nBlocks: {
text?: string;
id?: string;
blockName?: string;
blockType: 'i18n-text';
}[];
createdAt: string;
updatedAt: string;
}
@@ -201,6 +215,13 @@ export interface CollapsibleField {
};
};
someText?: string;
functionTitleField?: string;
componentTitleField?: string;
nestedTitle?: string;
arrayWithCollapsibles: {
innerCollapsible?: string;
id?: string;
}[];
createdAt: string;
updatedAt: string;
}
@@ -216,6 +237,30 @@ export interface ConditionalLogic {
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "date-fields".
*/
export interface DateField {
id: string;
default: string;
timeOnly?: string;
dayOnly?: string;
dayAndTime?: string;
monthOnly?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "radio-fields".
*/
export interface RadioField {
id: string;
radio?: 'one' | 'two' | 'three';
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "group-fields".
@@ -240,6 +285,49 @@ export interface GroupField {
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "indexed-fields".
*/
export interface IndexedField {
id: string;
text: string;
uniqueText?: string;
/**
* @minItems 2
* @maxItems 2
*/
point?: [number, number];
group: {
localizedUnique?: string;
/**
* @minItems 2
* @maxItems 2
*/
point?: [number, number];
};
collapsibleLocalizedUnique?: string;
collapsibleTextUnique?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "number-fields".
*/
export interface NumberField {
id: string;
number?: number;
min?: number;
max?: number;
positiveNumber?: number;
negativeNumber?: number;
decimalMin?: number;
decimalMax?: number;
defaultNumber?: number;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "point-fields".
@@ -266,6 +354,40 @@ export interface PointField {
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relationship-fields".
*/
export interface RelationshipField {
id: string;
relationship:
| {
value: string | TextField;
relationTo: 'text-fields';
}
| {
value: string | ArrayField;
relationTo: 'array-fields';
};
relationToSelf?: string | RelationshipField;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "text-fields".
*/
export interface TextField {
id: string;
text: string;
localizedText?: string;
i18nText?: string;
defaultFunction?: string;
defaultAsync?: string;
overrideLength?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "rich-text-fields".
@@ -276,6 +398,9 @@ export interface RichTextField {
richText: {
[k: string]: unknown;
}[];
richTextReadOnly?: {
[k: string]: unknown;
}[];
createdAt: string;
updatedAt: string;
}
@@ -286,6 +411,7 @@ export interface RichTextField {
export interface SelectField {
id: string;
select?: 'one' | 'two' | 'three';
selectReadOnly?: 'one' | 'two' | 'three';
selectHasMany?: ('one' | 'two' | 'three' | 'four' | 'five' | 'six')[];
createdAt: string;
updatedAt: string;
@@ -379,36 +505,6 @@ export interface TabsField {
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "text-fields".
*/
export interface TextField {
id: string;
text: string;
localizedText?: string;
defaultFunction?: string;
defaultAsync?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "number-fields".
*/
export interface NumberField {
id: string;
number?: number;
min?: number;
max?: number;
positiveNumber?: number;
negativeNumber?: number;
decimalMin?: number;
decimalMax?: number;
defaultNumber?: number;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "uploads".
@@ -416,6 +512,7 @@ export interface NumberField {
export interface Upload {
id: string;
text?: string;
media?: string | Upload;
url?: string;
filename?: string;
mimeType?: string;
@@ -425,46 +522,6 @@ export interface Upload {
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "indexed-fields".
*/
export interface IndexedField {
id: string;
text: string;
uniqueText?: string;
/**
* @minItems 2
* @maxItems 2
*/
point?: [number, number];
group: {
localizedUnique?: string;
/**
* @minItems 2
* @maxItems 2
*/
point?: [number, number];
};
collapsibleLocalizedUnique?: string;
collapsibleTextUnique?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "date-fields".
*/
export interface DateField {
id: string;
default: string;
timeOnly?: string;
dayOnly?: string;
dayAndTime?: string;
monthOnly?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".

View File

@@ -1,4 +1,6 @@
export class AdminUrlUtil {
account: string;
admin: string;
list: string;
@@ -6,6 +8,7 @@ export class AdminUrlUtil {
create: string;
constructor(serverURL: string, slug: string) {
this.account = `${serverURL}/admin/account`;
this.admin = `${serverURL}/admin`;
this.list = `${this.admin}/collections/${slug}`;
this.create = `${this.list}/create`;

View File

@@ -3,10 +3,10 @@ import { BeforeLoginHook, CollectionConfig } from '../../../../src/collections/c
import { AuthenticationError } from '../../../../src/errors';
import { devUser, regularUser } from '../../../credentials';
const beforeLoginHook: BeforeLoginHook = ({ user }) => {
const beforeLoginHook: BeforeLoginHook = ({ user, req }) => {
const isAdmin = user.roles.includes('admin') ? user : undefined;
if (!isAdmin) {
throw new AuthenticationError();
throw new AuthenticationError(req.t);
}
return user;
};