feat: support localized tab fields
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import ObjectID from 'bson-objectid';
|
||||
import { User } from '../../../../../auth';
|
||||
import { NonPresentationalField, fieldAffectsData, fieldHasSubFields } from '../../../../../fields/config/types';
|
||||
import {
|
||||
NonPresentationalField,
|
||||
fieldAffectsData,
|
||||
fieldHasSubFields,
|
||||
tabHasName,
|
||||
} from '../../../../../fields/config/types';
|
||||
import getValueWithDefault from '../../../../../fields/getDefaultValue';
|
||||
import { Fields, Field, Data } from '../types';
|
||||
import { iterateFields } from './iterateFields';
|
||||
@@ -213,9 +218,9 @@ export const addFieldStatePromise = async ({
|
||||
iterateFields({
|
||||
state,
|
||||
fields: tab.fields,
|
||||
data: tab.name ? data?.[tab.name] : data,
|
||||
data: tabHasName(tab) ? data?.[tab.name] : data,
|
||||
parentPassesCondition: passesCondition,
|
||||
path: tab.name ? `${path}${tab.name}.` : path,
|
||||
path: tabHasName(tab) ? `${path}${tab.name}.` : path,
|
||||
user,
|
||||
fieldPromises,
|
||||
fullData,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import RenderFields from '../../RenderFields';
|
||||
import withCondition from '../../withCondition';
|
||||
import { Props } from './types';
|
||||
import { fieldAffectsData } from '../../../../../fields/config/types';
|
||||
import { fieldAffectsData, tabHasName } from '../../../../../fields/config/types';
|
||||
import FieldDescription from '../../FieldDescription';
|
||||
import toKebabCase from '../../../../../utilities/toKebabCase';
|
||||
import { useCollapsible } from '../../../elements/Collapsible/provider';
|
||||
@@ -50,7 +50,7 @@ const TabsField: React.FC<Props> = (props) => {
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => setActive(i)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.label ? tab.label : (tabHasName(tab) && tab.name)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -74,7 +74,7 @@ const TabsField: React.FC<Props> = (props) => {
|
||||
fieldTypes={fieldTypes}
|
||||
fieldSchema={activeTab.fields.map((field) => ({
|
||||
...field,
|
||||
path: `${path ? `${path}.` : ''}${activeTab.name ? `${activeTab.name}.` : ''}${fieldAffectsData(field) ? field.name : ''}`,
|
||||
path: `${path ? `${path}.` : ''}${tabHasName(activeTab) ? `${activeTab.name}.` : ''}${fieldAffectsData(field) ? field.name : ''}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Field, fieldHasSubFields, fieldAffectsData } from '../../../../../fields/config/types';
|
||||
import { Field, fieldHasSubFields, fieldAffectsData, tabHasName } from '../../../../../fields/config/types';
|
||||
|
||||
const getRemainingColumns = (fields: Field[], useAsTitle: string): string[] => fields.reduce((remaining, field) => {
|
||||
if (fieldAffectsData(field) && field.name === useAsTitle) {
|
||||
@@ -17,7 +17,7 @@ const getRemainingColumns = (fields: Field[], useAsTitle: string): string[] => f
|
||||
...remaining,
|
||||
...field.tabs.reduce((tabFieldColumns, tab) => [
|
||||
...tabFieldColumns,
|
||||
...(tab.name ? [tab.name] : getRemainingColumns(tab.fields, useAsTitle)),
|
||||
...(tabHasName(tab) ? [tab.name] : getRemainingColumns(tab.fields, useAsTitle)),
|
||||
], []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -181,18 +181,22 @@ export const collapsible = baseField.keys({
|
||||
admin: baseAdminFields.default(),
|
||||
});
|
||||
|
||||
const tab = joi.object({
|
||||
name: joi.string().when('localized', { is: joi.exist(), then: joi.required() }),
|
||||
localized: joi.boolean(),
|
||||
label: joi.string().required(),
|
||||
fields: joi.array().items(joi.link('#field')).required(),
|
||||
description: joi.alternatives().try(
|
||||
joi.string(),
|
||||
componentSchema,
|
||||
),
|
||||
});
|
||||
|
||||
export const tabs = baseField.keys({
|
||||
type: joi.string().valid('tabs').required(),
|
||||
fields: joi.forbidden(),
|
||||
tabs: joi.array().items(joi.object({
|
||||
name: joi.string(),
|
||||
label: joi.string().required(),
|
||||
fields: joi.array().items(joi.link('#field')).required(),
|
||||
description: joi.alternatives().try(
|
||||
joi.string(),
|
||||
componentSchema,
|
||||
),
|
||||
})).required(),
|
||||
localized: joi.forbidden(),
|
||||
tabs: joi.array().items(tab).required(),
|
||||
admin: baseAdminFields.keys({
|
||||
description: joi.forbidden(),
|
||||
}),
|
||||
|
||||
@@ -181,14 +181,27 @@ export type CollapsibleField = Omit<FieldBase, 'name'> & {
|
||||
|
||||
export type TabsAdmin = Omit<Admin, 'description'>;
|
||||
|
||||
export type TabsField = Omit<FieldBase, 'admin' | 'name'> & {
|
||||
type BaseTab = {
|
||||
fields: Field[]
|
||||
description?: Description
|
||||
}
|
||||
|
||||
type NamedTab = BaseTab & {
|
||||
name: string
|
||||
localized?: boolean
|
||||
label?: string
|
||||
}
|
||||
|
||||
type UnnamedTab = BaseTab & {
|
||||
label: string
|
||||
localized?: never
|
||||
}
|
||||
|
||||
export type Tab = NamedTab | UnnamedTab
|
||||
|
||||
export type TabsField = Omit<FieldBase, 'admin' | 'name' | 'localized'> & {
|
||||
type: 'tabs';
|
||||
tabs: {
|
||||
name?: string
|
||||
label: string
|
||||
fields: Field[];
|
||||
description?: Description
|
||||
}[]
|
||||
tabs: Tab[]
|
||||
admin?: TabsAdmin
|
||||
}
|
||||
|
||||
@@ -451,4 +464,12 @@ export function fieldAffectsData(field: Field): field is FieldAffectingData {
|
||||
return 'name' in field && !fieldIsPresentationalOnly(field);
|
||||
}
|
||||
|
||||
export function tabHasName(tab: Tab): tab is NamedTab {
|
||||
return 'name' in tab;
|
||||
}
|
||||
|
||||
export function fieldIsLocalized(field: Field): boolean {
|
||||
return 'localized' in field && field.localized;
|
||||
}
|
||||
|
||||
export type HookName = 'beforeRead' | 'beforeChange' | 'beforeValidate' | 'afterChange' | 'afterRead';
|
||||
|
||||
@@ -15,7 +15,29 @@ import {
|
||||
GraphQLUnionType,
|
||||
} from 'graphql';
|
||||
import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars';
|
||||
import { Field, RadioField, RelationshipField, SelectField, UploadField, ArrayField, GroupField, RichTextField, NumberField, TextField, EmailField, TextareaField, CodeField, DateField, PointField, CheckboxField, BlockField, RowField, CollapsibleField, TabsField } from '../../fields/config/types';
|
||||
import {
|
||||
Field,
|
||||
RadioField,
|
||||
RelationshipField,
|
||||
SelectField,
|
||||
UploadField,
|
||||
ArrayField,
|
||||
GroupField,
|
||||
RichTextField,
|
||||
NumberField,
|
||||
TextField,
|
||||
EmailField,
|
||||
TextareaField,
|
||||
CodeField,
|
||||
DateField,
|
||||
PointField,
|
||||
CheckboxField,
|
||||
BlockField,
|
||||
RowField,
|
||||
CollapsibleField,
|
||||
TabsField,
|
||||
tabHasName,
|
||||
} from '../../fields/config/types';
|
||||
import formatName from '../utilities/formatName';
|
||||
import combineParentName from '../utilities/combineParentName';
|
||||
import withNullableType from './withNullableType';
|
||||
@@ -453,9 +475,15 @@ function buildObjectType({
|
||||
return objectTypeConfigWithCollapsibleFields;
|
||||
}, objectTypeConfig),
|
||||
tabs: (objectTypeConfig: ObjectTypeConfig, field: TabsField) => field.tabs.reduce((tabSchema, tab) => {
|
||||
if (tab.name) {
|
||||
if (tabHasName(tab)) {
|
||||
const fullName = combineParentName(parentName, toWords(tab.name, true));
|
||||
const type = buildObjectType(payload, fullName, tab.fields, fullName);
|
||||
const type = buildObjectType({
|
||||
payload,
|
||||
name: fullName,
|
||||
parentName: fullName,
|
||||
fields: tab.fields,
|
||||
forceNullable,
|
||||
});
|
||||
|
||||
return {
|
||||
...tabSchema,
|
||||
|
||||
@@ -4,7 +4,33 @@
|
||||
/* eslint-disable no-use-before-define */
|
||||
import { IndexDefinition, IndexOptions, Schema, SchemaOptions } from 'mongoose';
|
||||
import { SanitizedConfig } from '../config/types';
|
||||
import { ArrayField, Block, BlockField, CheckboxField, CodeField, CollapsibleField, DateField, EmailField, Field, fieldAffectsData, fieldIsPresentationalOnly, GroupField, NonPresentationalField, NumberField, PointField, RadioField, RelationshipField, RichTextField, RowField, SelectField, TabsField, TextareaField, TextField, UploadField } from '../fields/config/types';
|
||||
import {
|
||||
ArrayField,
|
||||
Block,
|
||||
BlockField,
|
||||
CheckboxField,
|
||||
CodeField,
|
||||
CollapsibleField,
|
||||
DateField,
|
||||
EmailField,
|
||||
Field,
|
||||
fieldAffectsData, fieldIsLocalized,
|
||||
fieldIsPresentationalOnly,
|
||||
GroupField,
|
||||
NonPresentationalField,
|
||||
NumberField,
|
||||
PointField,
|
||||
RadioField,
|
||||
RelationshipField,
|
||||
RichTextField,
|
||||
RowField,
|
||||
SelectField,
|
||||
tabHasName,
|
||||
TabsField,
|
||||
TextareaField,
|
||||
TextField,
|
||||
UploadField,
|
||||
} from '../fields/config/types';
|
||||
import sortableFieldTypes from '../fields/sortableFieldTypes';
|
||||
|
||||
export type BuildSchemaOptions = {
|
||||
@@ -22,14 +48,14 @@ type Index = {
|
||||
}
|
||||
|
||||
const formatBaseSchema = (field: NonPresentationalField, buildSchemaOptions: BuildSchemaOptions) => ({
|
||||
sparse: field.unique && field.localized,
|
||||
sparse: field.unique && fieldIsLocalized(field),
|
||||
unique: (!buildSchemaOptions.disableUnique && field.unique) || false,
|
||||
required: false,
|
||||
index: field.index || field.unique || false,
|
||||
});
|
||||
|
||||
const localizeSchema = (field: NonPresentationalField, schema, localization) => {
|
||||
if (field.localized && localization && Array.isArray(localization.locales)) {
|
||||
if (fieldIsLocalized(field) && localization && Array.isArray(localization.locales)) {
|
||||
return {
|
||||
type: localization.locales.reduce((localeSchema, locale) => ({
|
||||
...localeSchema,
|
||||
@@ -297,7 +323,7 @@ const fieldToSchemaMap = {
|
||||
},
|
||||
tabs: (field: TabsField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => {
|
||||
field.tabs.forEach((tab) => {
|
||||
if (tab.name) {
|
||||
if (tabHasName(tab)) {
|
||||
const formattedBaseSchema = formatBaseSchema(field, buildSchemaOptions);
|
||||
|
||||
const baseSchema = {
|
||||
@@ -311,7 +337,9 @@ const fieldToSchemaMap = {
|
||||
}),
|
||||
};
|
||||
|
||||
newFields[tab.name] = localizeSchema(field, baseSchema, config.localization);
|
||||
schema.add({
|
||||
[tab.name]: localizeSchema(field, baseSchema, config.localization),
|
||||
});
|
||||
} else {
|
||||
tab.fields.forEach((subField: Field) => {
|
||||
const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[subField.type];
|
||||
@@ -345,7 +373,7 @@ const fieldToSchemaMap = {
|
||||
|
||||
const baseSchema = {
|
||||
...formattedBaseSchema,
|
||||
required: required && field.fields.some((subField) => (!fieldIsPresentationalOnly(subField) && subField.required && !subField.localized && !subField?.admin?.condition && !subField?.access?.create)),
|
||||
required: required && field.fields.some((subField) => (!fieldIsPresentationalOnly(subField) && subField.required && !fieldIsLocalized(subField) && !subField?.admin?.condition && !subField?.access?.create)),
|
||||
type: buildSchema(config, field.fields, {
|
||||
options: {
|
||||
_id: false,
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Field, FieldAffectingData, fieldAffectsData, fieldHasSubFields, fieldIsPresentationalOnly, FieldPresentationalOnly } from '../fields/config/types';
|
||||
import {
|
||||
Field,
|
||||
FieldAffectingData,
|
||||
fieldAffectsData,
|
||||
fieldHasSubFields,
|
||||
fieldIsPresentationalOnly,
|
||||
FieldPresentationalOnly,
|
||||
tabHasName,
|
||||
} from '../fields/config/types';
|
||||
|
||||
const flattenFields = (fields: Field[], keepPresentationalFields?: boolean): (FieldAffectingData | FieldPresentationalOnly)[] => {
|
||||
return fields.reduce((fieldsToUse, field) => {
|
||||
@@ -22,7 +30,7 @@ const flattenFields = (fields: Field[], keepPresentationalFields?: boolean): (Fi
|
||||
...field.tabs.reduce((tabFields, tab) => {
|
||||
return [
|
||||
...tabFields,
|
||||
...(tab.name ? [tab] : flattenFields(tab.fields, keepPresentationalFields)),
|
||||
...(tabHasName(tab) ? [tab] : flattenFields(tab.fields, keepPresentationalFields)),
|
||||
];
|
||||
}, []),
|
||||
];
|
||||
|
||||
@@ -8,55 +8,34 @@
|
||||
export interface Config {}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts".
|
||||
* via the `definition` "autosave-global".
|
||||
*/
|
||||
export interface Post {
|
||||
export interface AutosaveGlobal {
|
||||
id: string;
|
||||
restrictedField?: string;
|
||||
_status?: 'draft' | 'published';
|
||||
title: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "autosave-posts".
|
||||
*/
|
||||
export interface AutosavePost {
|
||||
id: string;
|
||||
_status?: 'draft' | 'published';
|
||||
title: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "restricted".
|
||||
* via the `definition` "draft-posts".
|
||||
*/
|
||||
export interface Restricted {
|
||||
export interface DraftPost {
|
||||
id: string;
|
||||
name?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "read-only-collection".
|
||||
*/
|
||||
export interface ReadOnlyCollection {
|
||||
id: string;
|
||||
name?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "restricted-versions".
|
||||
*/
|
||||
export interface RestrictedVersion {
|
||||
id: string;
|
||||
name?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "sibling-data".
|
||||
*/
|
||||
export interface SiblingDatum {
|
||||
id: string;
|
||||
array?: {
|
||||
allowPublicReadability?: boolean;
|
||||
text?: string;
|
||||
id?: string;
|
||||
}[];
|
||||
_status?: 'draft' | 'published';
|
||||
title: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,14 @@ import type { CollectionConfig } from '../../../../src/collections/config/types'
|
||||
import { blocksField, blocksFieldSeedData } from '../Blocks';
|
||||
import { UIField } from './UIField';
|
||||
|
||||
export const tabsSlug = 'tabs-fields';
|
||||
|
||||
export const namedTabText = 'Some text in a named tab';
|
||||
export const namedTabDefaultValue = 'default text inside of a named tab';
|
||||
export const localizedTextValue = 'localized text';
|
||||
|
||||
const TabsFields: CollectionConfig = {
|
||||
slug: 'tabs-fields',
|
||||
slug: tabsSlug,
|
||||
versions: true,
|
||||
fields: [
|
||||
{
|
||||
@@ -95,6 +101,39 @@ const TabsFields: CollectionConfig = {
|
||||
name: 'tab',
|
||||
label: 'Tab with Name',
|
||||
description: 'This tab has a name, which should namespace the contained fields.',
|
||||
fields: [
|
||||
{
|
||||
name: 'array',
|
||||
labels: {
|
||||
singular: 'Item',
|
||||
plural: 'Items',
|
||||
},
|
||||
type: 'array',
|
||||
required: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'defaultValue',
|
||||
type: 'text',
|
||||
defaultValue: namedTabDefaultValue,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'localizedTab',
|
||||
label: 'Localized Tab',
|
||||
localized: true,
|
||||
description: 'This tab is localized and requires a name',
|
||||
fields: [
|
||||
{
|
||||
name: 'array',
|
||||
@@ -197,7 +236,21 @@ export const tabsDoc = {
|
||||
text: 'Here is some data for the third row, in a named tab',
|
||||
},
|
||||
],
|
||||
text: 'Some text in a named tab',
|
||||
text: namedTabText,
|
||||
},
|
||||
localizedTab: {
|
||||
array: [
|
||||
{
|
||||
text: "Hello, I'm the first row, in a named tab",
|
||||
},
|
||||
{
|
||||
text: 'Second row here, in a named tab',
|
||||
},
|
||||
{
|
||||
text: 'Here is some data for the third row, in a named tab',
|
||||
},
|
||||
],
|
||||
text: localizedTextValue,
|
||||
},
|
||||
textarea: 'Here is some text that goes in a textarea',
|
||||
anotherText: 'Super tired of writing this text',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { login, saveDocAndAssert } from '../helpers';
|
||||
import { textDoc } from './collections/Text';
|
||||
import { arrayFieldsSlug } from './collections/Array';
|
||||
import { pointFieldsSlug } from './collections/Point';
|
||||
import { tabsSlug } from './collections/Tabs';
|
||||
import wait from '../../src/utilities/wait';
|
||||
|
||||
const { beforeAll, describe } = test;
|
||||
@@ -88,7 +89,7 @@ describe('fields', () => {
|
||||
describe('fields - tabs', () => {
|
||||
let url: AdminUrlUtil;
|
||||
beforeAll(() => {
|
||||
url = new AdminUrlUtil(serverURL, 'tabs-fields');
|
||||
url = new AdminUrlUtil(serverURL, tabsSlug);
|
||||
});
|
||||
|
||||
test('should fill and retain a new value within a tab while switching tabs', async () => {
|
||||
@@ -101,7 +102,7 @@ describe('fields', () => {
|
||||
await page.locator('#field-textInRow').fill(textInRowValue);
|
||||
await page.locator('#field-numberInRow').fill(numberInRowValue);
|
||||
|
||||
await wait(500);
|
||||
await wait(100);
|
||||
|
||||
await page.locator('.tabs-field__tab-button:has-text("Tab with Array")').click();
|
||||
await page.locator('.tabs-field__tab-button:has-text("Tab with Row")').click();
|
||||
@@ -119,7 +120,7 @@ describe('fields', () => {
|
||||
await page.locator('.tabs-field__tab-button:has-text("Tab with Row")').click();
|
||||
await page.locator('#field-textInRow').fill(textInRowValue);
|
||||
|
||||
await wait(500);
|
||||
await wait(100);
|
||||
|
||||
// Go to Array tab, then back to Row. Make sure new value is still there
|
||||
await page.locator('.tabs-field__tab-button:has-text("Tab with Array")').click();
|
||||
@@ -131,7 +132,7 @@ describe('fields', () => {
|
||||
await page.locator('.tabs-field__tab-button:has-text("Tab with Array")').click();
|
||||
await page.click('#action-save', { delay: 100 });
|
||||
|
||||
await wait(500);
|
||||
await wait(100);
|
||||
|
||||
// Go back to row tab, make sure the new value is still present
|
||||
await page.locator('.tabs-field__tab-button:has-text("Tab with Row")').click();
|
||||
|
||||
@@ -4,11 +4,13 @@ import { RESTClient } from '../helpers/rest';
|
||||
import config from '../uploads/config';
|
||||
import payload from '../../src';
|
||||
import { pointDoc } from './collections/Point';
|
||||
import type { ArrayField, BlockField, GroupField, TabsField } from './payload-types';
|
||||
import type { ArrayField, GroupField } from './payload-types';
|
||||
import { arrayFieldsSlug, arrayDefaultValue, arrayDoc } from './collections/Array';
|
||||
import { groupFieldsSlug, groupDefaultChild, groupDefaultValue, groupDoc } from './collections/Group';
|
||||
import { defaultText } from './collections/Text';
|
||||
import { blocksFieldSeedData } from './collections/Blocks';
|
||||
import { localizedTextValue, namedTabDefaultValue, namedTabText, tabsDoc, tabsSlug } from './collections/Tabs';
|
||||
import { defaultNumber, numberDoc } from './collections/Number';
|
||||
|
||||
let client;
|
||||
@@ -280,6 +282,29 @@ describe('Fields', () => {
|
||||
expect(document.group.defaultParent).toStrictEqual(groupDefaultValue);
|
||||
expect(document.group.defaultChild).toStrictEqual(groupDefaultChild);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabs', () => {
|
||||
let document;
|
||||
|
||||
beforeAll(async () => {
|
||||
document = await payload.create<TabsField>({
|
||||
collection: tabsSlug,
|
||||
data: tabsDoc,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create with fields inside a named tab', async () => {
|
||||
expect(document.tab.text).toStrictEqual(namedTabText);
|
||||
});
|
||||
|
||||
it('should create with defaultValue inside a named tab', async () => {
|
||||
expect(document.tab.defaultValue).toStrictEqual(namedTabDefaultValue);
|
||||
});
|
||||
|
||||
it('should create with localized text inside a named tab', async () => {
|
||||
expect(document.localizedTab.text).toStrictEqual(localizedTextValue);
|
||||
});
|
||||
|
||||
it('should return empty object for groups when no data present', async () => {
|
||||
const doc = await payload.create<GroupField>({
|
||||
|
||||
Reference in New Issue
Block a user