Merge pull request #1223 from bigmistqke/feat/customizable-header-labels

WIP: customizable RowHeaders
This commit is contained in:
Jarrod Flesch
2022-11-16 15:06:16 -05:00
committed by GitHub
16 changed files with 333 additions and 37 deletions

View File

@@ -46,6 +46,7 @@ In addition to the default [field admin config](/docs/fields/overview#admin-conf
| Option | Description |
| ---------------------- | ------------------------------- |
| **`initCollapsed`** | Set the initial collapsed state |
| **`components.RowLabel`** | Function or React component to be rendered as the label on the array row. Recieves `({ data, index, path })` as args |
### Example
@@ -68,6 +69,10 @@ const ExampleCollection: CollectionConfig = {
plural: 'Slides',
},
fields: [ // required
{
name: 'title',
type: 'text',
}
{
name: 'image',
type: 'upload',
@@ -78,7 +83,14 @@ const ExampleCollection: CollectionConfig = {
name: 'caption',
type: 'text',
}
]
],
admin: {
components: {
RowLabel: ({ data, index }) => {
return data?.title || `Slide ${String(index).padStart(2, '0')}`;
},
},
},
}
]
};

View File

@@ -14,7 +14,7 @@ keywords: row, fields, config, configuration, documentation, Content Management
| Option | Description |
| -------------- | ------------------------------------------------------------------------- |
| **`label`** * | A label to render within the header of the collapsible component. |
| **`label`** * | A label to render within the header of the collapsible component. This can be a string, function or react component. Function/components receive `({ data, path })` as args. |
| **`fields`** * | Array of field types to nest within this Collapsible. |
| **`admin`** | Admin-specific configuration. See below for [more detail](#admin-config). |
@@ -38,9 +38,14 @@ const ExampleCollection: CollectionConfig = {
slug: 'example-collection',
fields: [
{
label: 'Header of collapsible goes here',
label: ({ data }) => data?.title || 'Untitled',
type: 'collapsible', // required
fields: [ // required
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'someTextField',
type: 'text',

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import AnimateHeight from 'react-animate-height';
import { fieldAffectsData } from '../../../../fields/config/types';
import { FieldAffectingData, fieldAffectsData } from '../../../../fields/config/types';
import SearchFilter from '../SearchFilter';
import ColumnSelector from '../ColumnSelector';
import WhereBuilder from '../WhereBuilder';
@@ -48,7 +48,7 @@ const ListControls: React.FC<Props> = (props) => {
fieldName={titleField && fieldAffectsData(titleField) ? titleField.name : undefined}
handleChange={handleWhereChange}
modifySearchQuery={modifySearchQuery}
fieldLabel={titleField && titleField.label ? titleField.label : undefined}
fieldLabel={titleField && fieldAffectsData(titleField) && titleField.label ? titleField.label : undefined}
listSearchableFields={textFieldsToBeSearched}
/>
<div className={`${baseClass}__buttons`}>

View File

@@ -20,7 +20,7 @@ const RenderFields: React.FC<Props> = (props) => {
readOnly: readOnlyOverride,
className,
forceRender,
indexPath: incomingIndexPath
indexPath: incomingIndexPath,
} = props;
const [hasRendered, setHasRendered] = useState(Boolean(forceRender));
@@ -107,11 +107,7 @@ const RenderFields: React.FC<Props> = (props) => {
className="missing-field"
key={fieldIndex}
>
No matched field found for
{' '}
&quot;
{field.label}
&quot;
{`No matched field found for "${field.label}"`}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { isComponent, Props } from './types';
import { useWatchForm } from '../Form/context';
const baseClass = 'row-label';
export const RowLabel: React.FC<Props> = ({ className, ...rest }) => {
return (
<span
style={{
pointerEvents: 'none',
}}
className={[
baseClass,
className,
].filter(Boolean).join(' ')}
>
<RowLabelContent {...rest} />
</span>
);
};
const RowLabelContent: React.FC<Omit<Props, 'className'>> = (props) => {
const {
path,
label,
rowNumber,
} = props;
const { getDataByPath, getSiblingData } = useWatchForm();
const collapsibleData = getSiblingData(path);
const arrayData = getDataByPath(path);
const data = arrayData || collapsibleData;
if (isComponent(label)) {
const Label = label;
return (
<Label
data={data}
path={path}
index={rowNumber}
/>
);
}
return (
<React.Fragment>
{typeof label === 'function' ? label({
data,
path,
index: rowNumber,
}) : label}
</React.Fragment>
);
};

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { Data } from '../Form/types';
export type Props = {
path: string;
label?: RowLabel;
rowNumber?: number;
className?: string,
}
export type RowLabelArgs = {
data: Data,
path: string,
index?: number,
}
export type RowLabelFunction = (args: RowLabelArgs) => string
export type RowLabelComponent = React.ComponentType<RowLabelArgs>
export type RowLabel = string | RowLabelFunction | RowLabelComponent
export function isComponent(label: RowLabel): label is RowLabelComponent {
return React.isValidElement(label);
}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useReducer, useState } from 'react';
import React, { useCallback, useEffect, useReducer } from 'react';
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import { useAuth } from '../../../utilities/Auth';
import withCondition from '../../withCondition';
@@ -22,6 +22,7 @@ import { usePreferences } from '../../../utilities/Preferences';
import { ArrayAction } from '../../../elements/ArrayAction';
import { scrollToID } from '../../../../utilities/scrollToID';
import HiddenInput from '../HiddenInput';
import { RowLabel } from '../../RowLabel';
import './index.scss';
@@ -45,6 +46,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
condition,
initCollapsed,
className,
components,
},
} = props;
@@ -61,6 +63,8 @@ const ArrayFieldType: React.FC<Props> = (props) => {
// eslint-disable-next-line react/destructuring-assignment
const label = props?.label ?? props?.labels?.singular;
const CustomRowLabel = components?.RowLabel || undefined;
const { preferencesKey } = useDocumentInfo();
const { getPreference } = usePreferences();
const { setPreference } = usePreferences();
@@ -251,6 +255,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
>
{rows.length > 0 && rows.map((row, i) => {
const rowNumber = i + 1;
const fallbackLabel = `${labels.singular} ${String(rowNumber).padStart(2, '0')}`;
return (
<Draggable
@@ -271,7 +276,13 @@ const ArrayFieldType: React.FC<Props> = (props) => {
className={`${baseClass}__row`}
key={row.id}
dragHandleProps={providedDrag.dragHandleProps}
header={`${labels.singular} ${rowNumber >= 10 ? rowNumber : `0${rowNumber}`}`}
header={(
<RowLabel
path={`${path}.${i}`}
label={CustomRowLabel || fallbackLabel}
rowNumber={rowNumber}
/>
)}
actions={!readOnly ? (
<ArrayAction
rowCount={rows.length}

View File

@@ -1,6 +1,8 @@
@import '../../../../scss/styles.scss';
.collapsible-field {
margin: 0 0 base(2);
&__label {
pointer-events: none;
}

View File

@@ -3,12 +3,12 @@ import RenderFields from '../../RenderFields';
import withCondition from '../../withCondition';
import { Props } from './types';
import { Collapsible } from '../../../elements/Collapsible';
import toKebabCase from '../../../../../utilities/toKebabCase';
import { usePreferences } from '../../../utilities/Preferences';
import { DocumentPreferences } from '../../../../../preferences/types';
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
import FieldDescription from '../../FieldDescription';
import { getFieldPath } from '../getFieldPath';
import { RowLabel } from '../../RowLabel';
import './index.scss';
@@ -33,13 +33,22 @@ const CollapsibleField: React.FC<Props> = (props) => {
const { getPreference, setPreference } = usePreferences();
const { preferencesKey } = useDocumentInfo();
const [collapsedOnMount, setCollapsedOnMount] = useState<boolean>();
const [fieldPreferencesKey] = useState(() => `collapsible-${toKebabCase(label)}`);
const fieldPreferencesKey = `collapsible-${indexPath.replace(/\./gi, '__')}`;
const onToggle = useCallback(async (newCollapsedState: boolean) => {
const existingPreferences: DocumentPreferences = await getPreference(preferencesKey);
setPreference(preferencesKey, {
...existingPreferences,
...path ? {
fields: {
...existingPreferences?.fields || {},
[path]: {
...existingPreferences?.fields?.[path],
collapsed: newCollapsedState,
},
},
} : {
fields: {
...existingPreferences?.fields || {},
[fieldPreferencesKey]: {
@@ -47,22 +56,28 @@ const CollapsibleField: React.FC<Props> = (props) => {
collapsed: newCollapsedState,
},
},
},
});
}, [preferencesKey, fieldPreferencesKey, getPreference, setPreference]);
}, [preferencesKey, fieldPreferencesKey, getPreference, setPreference, path]);
useEffect(() => {
const fetchInitialState = async () => {
const preferences = await getPreference(preferencesKey);
setCollapsedOnMount(Boolean(preferences?.fields?.[fieldPreferencesKey]?.collapsed ?? initCollapsed));
if (preferences) {
const initCollapsedFromPref = path ? preferences?.fields?.[path]?.collapsed : preferences?.fields?.[fieldPreferencesKey]?.collapsed;
setCollapsedOnMount(Boolean(initCollapsedFromPref));
} else {
setCollapsedOnMount(typeof initCollapsed === 'boolean' ? initCollapsed : false);
}
};
fetchInitialState();
}, [getPreference, preferencesKey, fieldPreferencesKey, initCollapsed]);
}, [getPreference, preferencesKey, fieldPreferencesKey, initCollapsed, path]);
if (typeof collapsedOnMount !== 'boolean') return null;
return (
<React.Fragment>
<div id={`field-${fieldPreferencesKey}${path ? `-${path.replace(/\./gi, '__')}` : ''}`}>
<Collapsible
initCollapsed={collapsedOnMount}
className={[
@@ -70,7 +85,12 @@ const CollapsibleField: React.FC<Props> = (props) => {
baseClass,
className,
].filter(Boolean).join(' ')}
header={<div className={`${baseClass}__label`}>{label}</div>}
header={(
<RowLabel
path={path}
label={label}
/>
)}
onToggle={onToggle}
>
<RenderFields
@@ -88,7 +108,7 @@ const CollapsibleField: React.FC<Props> = (props) => {
<FieldDescription
description={description}
/>
</React.Fragment>
</div>
);
};

View File

@@ -1,6 +1,12 @@
import joi from 'joi';
import { componentSchema } from '../../utilities/componentSchema';
export const baseAdminComponentFields = joi.object().keys({
Cell: componentSchema,
Field: componentSchema,
Filter: componentSchema,
}).default({});
export const baseAdminFields = joi.object().keys({
description: joi.alternatives().try(
joi.string(),
@@ -15,11 +21,7 @@ export const baseAdminFields = joi.object().keys({
hidden: joi.boolean().default(false),
disabled: joi.boolean().default(false),
condition: joi.func(),
components: joi.object().keys({
Cell: componentSchema,
Field: componentSchema,
Filter: componentSchema,
}).default({}),
components: baseAdminComponentFields,
});
export const baseField = joi.object().keys({
@@ -181,7 +183,10 @@ export const row = baseField.keys({
});
export const collapsible = baseField.keys({
label: joi.string().required(),
label: joi.alternatives().try(
joi.string(),
componentSchema,
),
type: joi.string().valid('collapsible').required(),
fields: joi.array().items(joi.link('#field')),
admin: baseAdminFields.default(),
@@ -236,6 +241,11 @@ export const array = baseField.keys({
joi.array().items(joi.object()),
joi.func(),
),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
RowLabel: componentSchema,
}).default({}),
}).default({}),
});
export const upload = baseField.keys({

View File

@@ -8,6 +8,7 @@ import { ConditionalDateProps } from '../../admin/components/elements/DatePicker
import { Description } from '../../admin/components/forms/FieldDescription/types';
import { User } from '../../auth';
import { Payload } from '../..';
import { RowLabel } from '../../admin/components/forms/RowLabel/types';
export type FieldHookArgs<T extends TypeWithID = any, P = any, S = any> = {
/** The data passed to update the document within create and update operations, and the full document itself in the afterRead hook. */
@@ -185,9 +186,9 @@ export type RowField = Omit<FieldBase, 'admin' | 'name'> & {
fields: Field[];
}
export type CollapsibleField = Omit<FieldBase, 'name'> & {
export type CollapsibleField = Omit<FieldBase, 'name' | 'label'> & {
type: 'collapsible';
label: string
label: RowLabel
fields: Field[];
admin?: Admin & {
initCollapsed?: boolean | false;
@@ -337,8 +338,11 @@ export type ArrayField = FieldBase & {
fields: Field[];
admin?: Admin & {
initCollapsed?: boolean | false;
}
}
components?: {
RowLabel?: RowLabel
} & Admin['components']
};
};
export type RadioField = FieldBase & {
type: 'radio';

View File

@@ -0,0 +1,6 @@
import React from 'react';
import { RowLabelComponent } from '../../../../src/admin/components/forms/RowLabel/types';
export const ArrayRowLabel: RowLabelComponent = ({ data }) => {
return <div style={{ textTransform: 'uppercase', color: 'coral' }}>{data.title || 'Untitled'}</div>;
};

View File

@@ -1,4 +1,5 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
import { ArrayRowLabel } from './LabelComponent';
export const arrayDefaultValue = [
{ text: 'row one' },
@@ -82,6 +83,38 @@ const ArrayFields: CollectionConfig = {
},
],
},
{
type: 'array',
name: 'rowLabelAsFunction',
fields: [
{
name: 'title',
type: 'text',
},
],
admin: {
description: 'Row labels rendered from a function.',
components: {
RowLabel: ({ data, fallback }) => data.title || fallback,
},
},
},
{
type: 'array',
name: 'rowLabelAsComponent',
fields: [
{
name: 'title',
type: 'text',
},
],
admin: {
description: 'Row labels rendered as react components.',
components: {
RowLabel: ArrayRowLabel,
},
},
},
],
};

View File

@@ -0,0 +1,6 @@
import React from 'react';
import { RowLabelComponent } from '../../../../src/admin/components/forms/RowLabel/types';
export const CollapsibleLabelComponent: RowLabelComponent = ({ data }) => {
return <div style={{ textTransform: 'uppercase', color: 'hotpink' }}>{data.innerCollapsible || 'Untitled'}</div>;
};

View File

@@ -1,7 +1,10 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
import { CollapsibleLabelComponent } from './LabelComponent';
export const collapsibleFieldsSlug = 'collapsible-fields';
const CollapsibleFields: CollectionConfig = {
slug: 'collapsible-fields',
slug: collapsibleFieldsSlug,
versions: true,
fields: [
{
@@ -73,6 +76,59 @@ const CollapsibleFields: CollectionConfig = {
},
],
},
{
label: ({ data }) => data.functionTitleField || 'Custom Collapsible Label',
type: 'collapsible',
admin: {
description: 'Collapsible label rendered from a function.',
initCollapsed: true,
},
fields: [
{
name: 'functionTitleField',
type: 'text',
},
],
},
{
label: ({ data }) => data?.componentTitleField || 'Untitled',
type: 'collapsible',
admin: {
description: 'Collapsible label rendered as a react component.',
},
fields: [
{
name: 'componentTitleField',
type: 'text',
},
{
type: 'collapsible',
label: ({ data }) => data?.nestedTitle || 'Nested Collapsible',
fields: [
{
type: 'text',
name: 'nestedTitle',
},
],
},
],
},
{
name: 'arrayWithCollapsibles',
type: 'array',
fields: [
{
label: CollapsibleLabelComponent,
type: 'collapsible',
fields: [
{
name: 'innerCollapsible',
type: 'text',
},
],
},
],
},
],
};
@@ -84,6 +140,11 @@ export const collapsibleDoc = {
textWithinSubGroup: 'hello, get out',
},
},
arrayWithCollapsibles: [
{
innerCollapsible: '',
},
],
};
export default CollapsibleFields;

View File

@@ -7,6 +7,7 @@ import { textDoc } from './collections/Text';
import { arrayFieldsSlug } from './collections/Array';
import { pointFieldsSlug } from './collections/Point';
import { tabsSlug } from './collections/Tabs';
import { collapsibleFieldsSlug } from './collections/Collapsible';
import wait from '../../src/utilities/wait';
import { relationshipFieldsSlug } from './collections/Relationship';
@@ -66,6 +67,33 @@ describe('fields', () => {
});
});
describe('fields - collapsible', () => {
let url: AdminUrlUtil;
beforeAll(() => {
url = new AdminUrlUtil(serverURL, collapsibleFieldsSlug);
});
test('should render CollapsibleLabel using a function', async () => {
const label = 'custom row label';
await page.goto(url.create);
await page.locator('#field-collapsible-3__1 >> #field-nestedTitle').fill(label);
await wait(100);
const customCollapsibleLabel = await page.locator('#field-collapsible-3__1 >> .row-label');
await expect(customCollapsibleLabel).toContainText(label);
});
test('should render CollapsibleLabel using a component', async () => {
const label = 'custom row label as component';
await page.goto(url.create);
await page.locator('#field-arrayWithCollapsibles >> .array-field__add-button-wrap >> button').click();
await page.locator('#field-collapsible-4__0-arrayWithCollapsibles__0 >> #field-arrayWithCollapsibles__0__innerCollapsible').fill(label);
await wait(100);
const customCollapsibleLabel = await page.locator(`#field-collapsible-4__0-arrayWithCollapsibles__0 >> .row-label :text("${label}")`);
await expect(customCollapsibleLabel).toHaveCSS('text-transform', 'uppercase');
});
});
describe('fields - array', () => {
let url: AdminUrlUtil;
beforeAll(() => {
@@ -85,6 +113,28 @@ describe('fields', () => {
await expect(field)
.toHaveValue('defaultValue');
});
test('should render RowLabel using a function', async () => {
const label = 'custom row label as function';
await page.goto(url.create);
await page.locator('#field-rowLabelAsFunction >> .array-field__add-button-wrap >> button').click();
await page.locator('#field-rowLabelAsFunction__0__title').fill(label);
await wait(100);
const customRowLabel = await page.locator('#rowLabelAsFunction-row-0 >> .row-label');
await expect(customRowLabel).toContainText(label);
});
test('should render RowLabel using a component', async () => {
const label = 'custom row label as component';
await page.goto(url.create);
await page.locator('#field-rowLabelAsComponent >> .array-field__add-button-wrap >> button').click();
await page.locator('#field-rowLabelAsComponent__0__title').fill(label);
await wait(100);
const customRowLabel = await page.locator('#rowLabelAsComponent-row-0 >> .row-label :text("custom row label")');
await expect(customRowLabel).toHaveCSS('text-transform', 'uppercase');
});
});
describe('fields - tabs', () => {