feat: support localized tab fields

This commit is contained in:
Dan Ribbens
2022-08-05 09:19:40 -04:00
parent 4b7b04d5fa
commit a83921a2fe
12 changed files with 233 additions and 81 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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