feat: drag-and-drop columns (#2142)

This commit is contained in:
Jacob Fletcher
2023-02-28 09:35:03 -05:00
committed by GitHub
parent 523d9d4952
commit e2c65e3fa5
45 changed files with 2763 additions and 2150 deletions

View File

@@ -14,6 +14,7 @@ import Version from './views/Version';
import { DocumentInfoProvider } from './utilities/DocumentInfo';
import { useLocale } from './utilities/Locale';
import { LoadingOverlayToggle } from './elements/Loading';
import { TableColumnsProvider } from './elements/TableColumns';
const Dashboard = lazy(() => import('./views/Dashboard'));
const ForgotPassword = lazy(() => import('./views/ForgotPassword'));
@@ -188,10 +189,12 @@ const Routes = () => {
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.read?.permission) {
return (
<List
{...routeProps}
collection={collection}
/>
<TableColumnsProvider collection={collection}>
<List
{...routeProps}
collection={collection}
/>
</TableColumnsProvider>
);
}

View File

@@ -46,7 +46,8 @@ export const Collapsible: React.FC<Props> = ({
{dragHandleProps && (
<div
className={`${baseClass}__drag`}
{...dragHandleProps}
{...dragHandleProps.attributes}
{...dragHandleProps.listeners}
>
<DragHandle />
</div>

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { DraggableProvidedDragHandleProps } from 'react-beautiful-dnd';
import { DragHandleProps } from '../DraggableSortable/DraggableSortableItem/types';
export type Props = {
collapsed?: boolean
@@ -9,5 +9,5 @@ export type Props = {
children: React.ReactNode
onToggle?: (collapsed: boolean) => void
initCollapsed?: boolean
dragHandleProps?: DraggableProvidedDragHandleProps
dragHandleProps?: DragHandleProps
}

View File

@@ -1,6 +1,8 @@
@import '../../../scss/styles.scss';
.column-selector {
display: flex;
flex-wrap: wrap;
background: var(--theme-elevation-50);
padding: base(1) base(1) base(.5);

View File

@@ -1,12 +1,14 @@
import React, { useEffect, useId, useState } from 'react';
import React, { useId } from 'react';
import { useTranslation } from 'react-i18next';
import flattenTopLevelFields from '../../../../utilities/flattenTopLevelFields';
import Pill from '../Pill';
import Plus from '../../icons/Plus';
import X from '../../icons/X';
import { Props } from './types';
import { getTranslation } from '../../../../utilities/getTranslation';
import { useEditDepth } from '../../utilities/EditDepth';
import DraggableSortable from '../DraggableSortable';
import { useTableColumns } from '../TableColumns';
import './index.scss';
const baseClass = 'column-selector';
@@ -14,49 +16,58 @@ const baseClass = 'column-selector';
const ColumnSelector: React.FC<Props> = (props) => {
const {
collection,
columns,
setColumns,
} = props;
const [fields, setFields] = useState(() => flattenTopLevelFields(collection.fields, true));
useEffect(() => {
setFields(flattenTopLevelFields(collection.fields, true));
}, [collection.fields]);
const {
columns,
toggleColumn,
moveColumn,
} = useTableColumns();
const { i18n } = useTranslation();
const uuid = useId();
const editDepth = useEditDepth();
if (!columns) { return null; }
return (
<div className={baseClass}>
{fields && fields.map((field, i) => {
const isEnabled = columns.find((column) => column === field.name);
<DraggableSortable
className={baseClass}
ids={columns.map((col) => col.accessor)}
onDragEnd={({ moveFromIndex, moveToIndex }) => {
moveColumn({
fromIndex: moveFromIndex,
toIndex: moveToIndex,
});
}}
>
{columns.map((col, i) => {
const {
accessor,
active,
label,
name,
} = col;
return (
<Pill
draggable
id={accessor}
onClick={() => {
let newState = [...columns];
if (isEnabled) {
newState = newState.filter((remainingColumn) => remainingColumn !== field.name);
} else {
newState.unshift(field.name);
}
setColumns(newState);
toggleColumn(accessor);
}}
alignIcon="left"
key={`${collection.slug}-${field.name || i}${editDepth ? `-${editDepth}-` : ''}${uuid}`}
icon={isEnabled ? <X /> : <Plus />}
key={`${collection.slug}-${col.name || i}${editDepth ? `-${editDepth}-` : ''}${uuid}`}
icon={active ? <X /> : <Plus />}
className={[
`${baseClass}__column`,
isEnabled && `${baseClass}__column--active`,
active && `${baseClass}__column--active`,
].filter(Boolean).join(' ')}
>
{getTranslation(field.label || field.name, i18n)}
{getTranslation(label || name, i18n)}
</Pill>
);
})}
</div>
</DraggableSortable>
);
};

View File

@@ -2,6 +2,4 @@ import { SanitizedCollectionConfig } from '../../../../collections/config/types'
export type Props = {
collection: SanitizedCollectionConfig,
columns: string[]
setColumns: (columns: string[]) => void,
}

View File

@@ -0,0 +1,37 @@
import { UseDraggableArguments } from '@dnd-kit/core';
import React, { Fragment } from 'react';
import { useDraggableSortable } from '../useDraggableSortable';
import { ChildFunction } from './types';
export const DraggableSortableItem: React.FC<UseDraggableArguments & {
children: ChildFunction
}> = (props) => {
const {
id,
disabled,
children,
} = props;
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggableSortable({
id,
disabled,
});
return (
<Fragment>
{children({
attributes: {
...attributes,
style: {
cursor: isDragging ? 'grabbing' : 'grab',
},
},
listeners,
setNodeRef,
transform,
})}
</Fragment>
);
};
export default DraggableSortableItem;

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { UseDraggableArguments } from '@dnd-kit/core';
// eslint-disable-next-line import/no-unresolved
import { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import { UseDraggableSortableReturn } from '../useDraggableSortable/types';
export type DragHandleProps = UseDraggableArguments & {
attributes: UseDraggableArguments['attributes']
listeners: SyntheticListenerMap
}
export type ChildFunction = (args: UseDraggableSortableReturn) => React.ReactNode;
export type Props = UseDraggableArguments & {
children: ChildFunction
}

View File

@@ -0,0 +1,75 @@
import React, { useCallback, useId } from 'react';
import { SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import {
DragEndEvent,
useDroppable,
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { Props } from './types';
const DraggableSortable: React.FC<Props> = (props) => {
const {
onDragEnd,
ids,
className,
children,
} = props;
const id = useId();
const { setNodeRef } = useDroppable({
id,
});
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 5,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!active || !over) return;
if (typeof onDragEnd === 'function') {
onDragEnd({
event,
moveFromIndex: ids.findIndex((_id) => _id === active.id),
moveToIndex: ids.findIndex((_id) => _id === over.id),
});
}
}, [onDragEnd, ids]);
return (
<DndContext
onDragEnd={handleDragEnd}
sensors={sensors}
collisionDetection={closestCenter}
>
<SortableContext items={ids}>
<div
className={className}
ref={setNodeRef}
>
{children}
</div>
</SortableContext>
</DndContext>
);
};
export default DraggableSortable;

View File

@@ -0,0 +1,15 @@
/* eslint-disable import/no-extraneous-dependencies */
import { DragEndEvent } from '@dnd-kit/core';
import { Ref } from 'react';
export type Props = {
children: React.ReactNode;
className?: string;
ids: string[];
droppableRef?: Ref<HTMLElement>;
onDragEnd: (e: {
event: DragEndEvent,
moveFromIndex: number,
moveToIndex: number,
}) => void;
}

View File

@@ -0,0 +1,28 @@
import { useSortable } from '@dnd-kit/sortable';
import { UseDraggableArguments } from '@dnd-kit/core';
import { UseDraggableSortableReturn } from './types';
export const useDraggableSortable = (props: UseDraggableArguments): UseDraggableSortableReturn => {
const {
id,
disabled,
} = props;
const { attributes, listeners, setNodeRef, transform, isDragging } = useSortable({
id,
disabled,
});
return {
attributes: {
...attributes,
style: {
cursor: isDragging ? 'grabbing' : 'grab',
},
},
isDragging,
listeners,
setNodeRef,
transform: transform && `translate3d(${transform.x}px, ${transform.y}px, 0)`,
};
};

View File

@@ -0,0 +1,11 @@
import { HTMLAttributes } from 'react';
/* eslint-disable import/no-unresolved */
import { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
export type UseDraggableSortableReturn = {
attributes: HTMLAttributes<unknown>
listeners: SyntheticListenerMap
setNodeRef: (node: HTMLElement | null) => void
transform: string
isDragging?: boolean
}

View File

@@ -22,8 +22,6 @@ const ListControls: React.FC<Props> = (props) => {
collection,
enableColumns = true,
enableSort = false,
columns,
setColumns,
handleSortChange,
handleWhereChange,
modifySearchQuery = true,
@@ -95,11 +93,7 @@ const ListControls: React.FC<Props> = (props) => {
className={`${baseClass}__columns`}
height={visibleDrawer === 'columns' ? 'auto' : 0}
>
<ColumnSelector
collection={collection}
columns={columns}
setColumns={setColumns}
/>
<ColumnSelector collection={collection} />
</AnimateHeight>
)}
<AnimateHeight

View File

@@ -1,18 +1,17 @@
import { Where } from '../../../../types';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { Column } from '../Table/types';
export type Props = {
enableColumns?: boolean,
enableSort?: boolean,
enableColumns?: boolean
enableSort?: boolean
modifySearchQuery?: boolean
handleSortChange?: (sort: string) => void
handleWhereChange?: (where: Where) => void
columns?: string[]
setColumns?: (columns: string[]) => void,
collection: SanitizedCollectionConfig,
collection: SanitizedCollectionConfig
}
export type ListControls = {
where?: unknown
columns?: string[]
columns?: Partial<Column>[]
}

View File

@@ -1,4 +1,4 @@
import React, { Fragment, useCallback, useEffect, useReducer, useState } from 'react';
import React, { useCallback, useEffect, useReducer, useState } from 'react';
import { useModal } from '@faceless-ui/modal';
import { useTranslation } from 'react-i18next';
import { ListDrawerProps } from './types';
@@ -16,37 +16,11 @@ import { useDocumentDrawer } from '../DocumentDrawer';
import Pill from '../Pill';
import X from '../../icons/X';
import ViewDescription from '../ViewDescription';
import { Column } from '../Table/types';
import getInitialColumnState from '../../views/collections/List/getInitialColumns';
import buildListColumns from '../../views/collections/List/buildColumns';
import formatFields from '../../views/collections/List/formatFields';
import { ListPreferences } from '../../views/collections/List/types';
import { usePreferences } from '../../utilities/Preferences';
import { Field } from '../../../../fields/config/types';
import { baseClass } from '.';
const buildColumns = ({
collectionConfig,
columns,
onSelect,
t,
}) => buildListColumns({
collection: collectionConfig,
columns,
t,
cellProps: [{
link: false,
onClick: ({ collection, rowData }) => {
if (typeof onSelect === 'function') {
onSelect({
docID: rowData.id,
collectionConfig: collection,
});
}
},
className: `${baseClass}__first-cell`,
}],
});
import { TableColumnsProvider } from '../TableColumns';
export const ListDrawerContent: React.FC<ListDrawerProps> = ({
drawerSlug,
@@ -58,7 +32,7 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
}) => {
const { t, i18n } = useTranslation(['upload', 'general']);
const { permissions } = useAuth();
const { getPreference, setPreference } = usePreferences();
const { setPreference } = usePreferences();
const { isModalOpen, closeModal } = useModal();
const [limit, setLimit] = useState<number>();
const [sort, setSort] = useState(null);
@@ -78,15 +52,9 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
const [fields, setFields] = useState<Field[]>(() => formatFields(selectedCollectionConfig, t));
const [tableColumns, setTableColumns] = useState<Column[]>(() => {
const initialColumns = getInitialColumnState(fields, selectedCollectionConfig.admin.useAsTitle, selectedCollectionConfig.admin.defaultColumns);
return buildColumns({
collectionConfig: selectedCollectionConfig,
columns: initialColumns,
t,
onSelect,
});
});
useEffect(() => {
setFields(formatFields(selectedCollectionConfig, t));
}, [selectedCollectionConfig, t]);
// allow external control of selected collection, same as the initial state logic above
useEffect(() => {
@@ -97,8 +65,6 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
}
}, [selectedCollection, enabledCollectionConfigs, onSelect, t]);
const activeColumnNames = tableColumns.map(({ accessor }) => accessor);
const stringifiedActiveColumns = JSON.stringify(activeColumnNames);
const preferenceKey = `${selectedCollectionConfig.slug}-list`;
// this is the 'create new' drawer
@@ -153,41 +119,14 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
setParams(params);
}, [setParams, page, sort, where, limit, cacheBust, filterOptions, selectedCollectionConfig]);
useEffect(() => {
const syncColumnsFromPrefs = async () => {
const currentPreferences = await getPreference<ListPreferences>(preferenceKey);
const newFields = formatFields(selectedCollectionConfig, t);
setFields(newFields);
const initialColumns = getInitialColumnState(newFields, selectedCollectionConfig.admin.useAsTitle, selectedCollectionConfig.admin.defaultColumns);
setTableColumns(buildColumns({
collectionConfig: selectedCollectionConfig,
columns: currentPreferences?.columns || initialColumns,
t,
onSelect,
}));
};
syncColumnsFromPrefs();
}, [t, getPreference, preferenceKey, onSelect, selectedCollectionConfig]);
useEffect(() => {
const newPreferences = {
limit,
sort,
columns: JSON.parse(stringifiedActiveColumns),
};
setPreference(preferenceKey, newPreferences);
}, [sort, limit, stringifiedActiveColumns, setPreference, preferenceKey]);
const setActiveColumns = useCallback((columns: string[]) => {
setTableColumns(buildColumns({
collectionConfig: selectedCollectionConfig,
columns,
t,
onSelect,
}));
}, [selectedCollectionConfig, t, onSelect]);
}, [sort, limit, setPreference, preferenceKey]);
const onCreateNew = useCallback(({ doc }) => {
if (typeof onSelect === 'function') {
@@ -206,7 +145,21 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
}
return (
<Fragment>
<TableColumnsProvider
collection={selectedCollectionConfig}
cellProps={[{
link: false,
onClick: ({ collection: rowColl, rowData }) => {
if (typeof onSelect === 'function') {
onSelect({
docID: rowData.id,
collectionConfig: rowColl,
});
}
},
className: `${baseClass}__first-cell`,
}]}
>
<DocumentInfoProvider collection={selectedCollectionConfig}>
<RenderCustomComponent
DefaultComponent={DefaultList}
@@ -264,12 +217,9 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
data,
limit: limit || selectedCollectionConfig?.admin?.pagination?.defaultLimit,
setLimit,
tableColumns,
setColumns: setActiveColumns,
setSort,
newDocumentURL: null,
hasCreatePermission,
columnNames: activeColumnNames,
disableEyebrow: true,
modifySearchParams: false,
onCardClick: (doc) => {
@@ -290,6 +240,6 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
/>
</DocumentInfoProvider>
<DocumentDrawer onSave={onCreateNew} />
</Fragment>
</TableColumnsProvider>
);
};

View File

@@ -3,18 +3,17 @@
.pill {
font-size: 1rem;
line-height: base(1);
border: 0;
display: inline-flex;
vertical-align: middle;
background: var(--theme-elevation-150);
color: var(--theme-elevation-800);
border-radius: $style-radius-s;
padding: 0 base(.25);
padding-left: base(.0875 + .25);
cursor: default;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: 0;
padding: 0 base(.25);
align-items: center;
&:active,
&:focus {
@@ -26,6 +25,10 @@
text-decoration: none;
}
&--is-dragging {
cursor: grabbing;
}
&--has-icon {
svg {
display: block;
@@ -33,11 +36,11 @@
}
&--align-icon-left {
padding-left: base(.125);
padding-left: 0;
}
&--align-icon-right {
padding-right: base(.125);
padding-right: 0;
}
&--style-white {

View File

@@ -1,20 +1,29 @@
import React from 'react';
import React, { ElementType } from 'react';
import { Link } from 'react-router-dom';
import { Props, RenderedTypeProps } from './types';
import { useDraggableSortable } from '../DraggableSortable/useDraggableSortable';
import './index.scss';
const baseClass = 'pill';
const Pill: React.FC<Props> = ({
children,
className,
to,
icon,
alignIcon = 'right',
onClick,
pillStyle = 'light',
}) => {
const Pill: React.FC<Props> = (props) => {
const {
id,
className,
to,
icon,
alignIcon = 'right',
onClick,
pillStyle = 'light',
draggable,
children,
} = props;
const { attributes, listeners, transform, setNodeRef, isDragging } = useDraggableSortable({
id,
});
const classes = [
baseClass,
`${baseClass}--style-${pillStyle}`,
@@ -23,19 +32,29 @@ const Pill: React.FC<Props> = ({
(to || onClick) && `${baseClass}--has-action`,
icon && `${baseClass}--has-icon`,
icon && `${baseClass}--align-icon-${alignIcon}`,
draggable && `${baseClass}--draggable`,
isDragging && `${baseClass}--is-dragging`,
].filter(Boolean).join(' ');
let RenderedType: string | React.FC<RenderedTypeProps> = 'div';
let Element: ElementType | React.FC<RenderedTypeProps> = 'div';
if (onClick && !to) RenderedType = 'button';
if (to) RenderedType = Link;
if (onClick && !to) Element = 'button';
if (to) Element = Link;
return (
<RenderedType
<Element
className={classes}
onClick={onClick}
type={RenderedType === 'button' ? 'button' : undefined}
type={Element === 'button' ? 'button' : undefined}
to={to || undefined}
{...draggable ? {
...listeners,
...attributes,
style: {
transform,
},
ref: setNodeRef,
} : {}}
onClick={onClick}
>
{(icon && alignIcon === 'left') && (
<React.Fragment>
@@ -48,7 +67,7 @@ const Pill: React.FC<Props> = ({
{icon}
</React.Fragment>
)}
</RenderedType>
</Element>
);
};

View File

@@ -6,6 +6,8 @@ export type Props = {
alignIcon?: 'left' | 'right',
onClick?: () => void,
pillStyle?: 'white' | 'light' | 'dark' | 'light-gray' | 'warning' | 'success',
draggable?: boolean,
id?: string
}
export type RenderedTypeProps = {

View File

@@ -7,9 +7,5 @@
border: $style-stroke-width-s solid var(--theme-elevation-800);
line-height: calc(#{$baseline} - #{$style-stroke-width-s * 2});
margin: base(.25) base(.5) base(.25) 0;
&.draggable {
cursor: grab;
}
}
}

View File

@@ -3,7 +3,7 @@ import {
MultiValueProps,
components as SelectComponents,
} from 'react-select';
import { useSortable } from '@dnd-kit/sortable';
import { useDraggableSortable } from '../../DraggableSortable/useDraggableSortable';
import { Option as OptionType } from '../types';
import './index.scss';
@@ -26,16 +26,21 @@ export const MultiValue: React.FC<MultiValueProps<OptionType>> = (props) => {
},
} = props;
const { attributes, listeners, setNodeRef, transform } = useSortable({
id: value as string,
});
const classes = [
baseClass,
className,
!isDisabled && 'draggable',
].filter(Boolean).join(' ');
const {
attributes,
listeners,
setNodeRef,
transform,
} = useDraggableSortable({
id: value.toString(),
});
return (
<SelectComponents.MultiValue
{...props}
@@ -51,9 +56,7 @@ export const MultiValue: React.FC<MultiValueProps<OptionType>> = (props) => {
}
},
style: {
...transform ? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
} : {},
transform,
},
}}
selectProps={{

View File

@@ -1,5 +1,9 @@
@import '../../../scss/styles';
.react-select-container {
width: 100%;
}
.react-select {
.rs__control {
@include formInput;

View File

@@ -1,21 +1,7 @@
import React, { useCallback, useId } from 'react';
import {
DragEndEvent,
useDroppable,
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import React from 'react';
import Select from 'react-select';
import { useTranslation } from 'react-i18next';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { arrayMove } from '@dnd-kit/sortable';
import { Props } from './types';
import Chevron from '../../icons/Chevron';
import { getTranslation } from '../../../../utilities/getTranslation';
@@ -26,6 +12,8 @@ import { ValueContainer } from './ValueContainer';
import { ClearIndicator } from './ClearIndicator';
import { MultiValueRemove } from './MultiValueRemove';
import { Control } from './Control';
import DraggableSortable from '../DraggableSortable';
import './index.scss';
const SelectAdapter: React.FC<Props> = (props) => {
@@ -45,7 +33,6 @@ const SelectAdapter: React.FC<Props> = (props) => {
isLoading,
onMenuOpen,
components,
droppableRef,
selectProps,
} = props;
@@ -73,7 +60,6 @@ const SelectAdapter: React.FC<Props> = (props) => {
onMenuOpen={onMenuOpen}
selectProps={{
...selectProps,
droppableRef,
}}
components={{
ValueContainer,
@@ -96,51 +82,23 @@ const SortableSelect: React.FC<Props> = (props) => {
value,
} = props;
const uuid = useId();
const { setNodeRef } = useDroppable({
id: uuid,
});
const onDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!active || !over) return;
let sorted = value;
if (value && Array.isArray(value)) {
const oldIndex = value.findIndex((item) => item.value === active.id);
const newIndex = value.findIndex((item) => item.value === over.id);
sorted = arrayMove(value, oldIndex, newIndex);
}
onChange(sorted);
}, [onChange, value]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
let ids: string[] = [];
if (value) ids = Array.isArray(value) ? value.map((item) => item?.value as string) : [value?.value as string]; // TODO: fix these types
return (
<DndContext
onDragEnd={onDragEnd}
sensors={sensors}
collisionDetection={closestCenter}
<DraggableSortable
ids={ids}
className="react-select-container"
onDragEnd={({ moveFromIndex, moveToIndex }) => {
let sorted = value;
if (value && Array.isArray(value)) {
sorted = arrayMove(value, moveFromIndex, moveToIndex);
}
onChange(sorted);
}}
>
<SortableContext items={ids}>
<SelectAdapter
{...props}
droppableRef={setNodeRef}
/>
</SortableContext>
</DndContext>
<SelectAdapter {...props} />
</DraggableSortable>
);
};

View File

@@ -11,7 +11,6 @@ export type OptionGroup = {
}
export type Props = {
droppableRef?: Ref<HTMLElement>
className?: string
value?: Option | Option[],
onChange?: (value: any) => void, // eslint-disable-line @typescript-eslint/no-explicit-any

View File

@@ -1,52 +1,70 @@
import React from 'react';
import { Props } from './types';
import { useTableColumns } from '../TableColumns';
import './index.scss';
const baseClass = 'table';
const Table: React.FC<Props> = ({ columns, data }) => {
if (columns && columns.length > 0) {
export const Table: React.FC<Props> = ({ data }) => {
const {
columns,
} = useTableColumns();
const activeColumns = columns.filter((col) => col.active);
if (!activeColumns || activeColumns.length === 0) {
return (
<div className={baseClass}>
<table
cellPadding="0"
cellSpacing="0"
>
<thead>
<tr>
{columns.map((col, i) => (
<th
key={i}
id={`heading-${col.accessor}`}
>
{col.components.Heading}
</th>
))}
</tr>
</thead>
<tbody>
{data && data.map((row, rowIndex) => (
<tr
key={rowIndex}
className={`row-${rowIndex + 1}`}
<div>
No columns selected
</div>
);
}
return (
<div className={baseClass}>
<table
cellPadding="0"
cellSpacing="0"
>
<thead>
<tr>
{activeColumns.map((col, i) => (
<th
key={i}
id={`heading-${col.accessor}`}
>
{columns.map((col, colIndex) => (
{col.components.Heading}
</th>
))}
</tr>
</thead>
<tbody>
{data && data.map((row, rowIndex) => (
<tr
key={rowIndex}
className={`row-${rowIndex + 1}`}
>
{columns.map((col, colIndex) => {
const { active } = col;
if (!active) return null;
return (
<td
key={colIndex}
className={`cell-${col.accessor}`}
>
{col.components.renderCell(row, row[col.accessor])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
return null;
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
};
export default Table;

View File

@@ -1,14 +1,17 @@
import React from 'react';
import { FieldBase } from '../../../../fields/config/types';
export type Column = {
accessor: string,
accessor: string
label: FieldBase['label']
name: FieldBase['name']
active: boolean
components: {
Heading: React.ReactNode,
renderCell: (row: any, data: any) => React.ReactNode,
Heading: React.ReactNode
renderCell: (row: any, data: any) => React.ReactNode
},
}
export type Props = {
columns: Column[],
data: unknown[]
}

View File

@@ -0,0 +1,91 @@
import React from 'react';
import type { TFunction } from 'react-i18next';
import Cell from '../../views/collections/List/Cell';
import SortColumn from '../SortColumn';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { Column } from '../Table/types';
import { fieldIsPresentationalOnly } from '../../../../fields/config/types';
import flattenFields from '../../../../utilities/flattenTopLevelFields';
import { Props as CellProps } from '../../views/collections/List/Cell/types';
const buildColumns = ({
collection,
columns,
t,
cellProps,
}: {
collection: SanitizedCollectionConfig,
columns: Pick<Column, 'accessor' | 'active'>[],
t: TFunction,
cellProps?: Partial<CellProps>[]
}): Column[] => {
const flattenedFields = flattenFields([
...collection.fields,
{
name: 'id',
type: 'text',
label: 'ID',
},
{
name: 'updatedAt',
type: 'date',
label: t('updatedAt'),
},
{
name: 'createdAt',
type: 'date',
label: t('createdAt'),
},
]);
// sort the fields to the order of activeColumns
const sortedFields = flattenedFields.sort((a, b) => {
const aIndex = columns.findIndex((column) => column.accessor === a.name);
const bIndex = columns.findIndex((column) => column.accessor === b.name);
if (aIndex === -1 && bIndex === -1) return 0;
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
const firstActiveColumn = sortedFields.find((field) => columns.find((column) => column.accessor === field.name)?.active);
const cols: Column[] = sortedFields.map((field, colIndex) => {
const isActive = columns.find((column) => column.accessor === field.name)?.active || false;
const isFirstActive = firstActiveColumn?.name === field.name;
return {
active: isActive,
accessor: field.name,
name: field.name,
label: field.label,
components: {
Heading: (
<SortColumn
label={field.label || field.name}
name={field.name}
disable={(('disableSort' in field && Boolean(field.disableSort)) || fieldIsPresentationalOnly(field)) || undefined}
/>
),
renderCell: (rowData, cellData) => {
return (
<Cell
key={JSON.stringify(cellData)}
field={field}
colIndex={colIndex}
collection={collection}
rowData={rowData}
cellData={cellData}
link={isFirstActive}
{...cellProps?.[colIndex] || {}}
/>
);
},
},
};
});
return cols;
};
export default buildColumns;

View File

@@ -0,0 +1,96 @@
import { TFunction } from 'react-i18next';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { Column } from '../Table/types';
import buildColumns from './buildColumns';
type TOGGLE = {
type: 'toggle',
payload: {
column: string
t: TFunction
collection: SanitizedCollectionConfig
}
}
type SET = {
type: 'set',
payload: {
columns: Pick<Column, 'accessor' | 'active'>[]
t: TFunction
collection: SanitizedCollectionConfig
}
}
type MOVE = {
type: 'move',
payload: {
fromIndex: number
toIndex: number
t: TFunction
collection: SanitizedCollectionConfig
}
}
export type Action = TOGGLE | SET | MOVE;
export const columnReducer = (state: Column[], action: Action): Column[] => {
switch (action.type) {
case 'toggle': {
const {
column,
t,
collection,
} = action.payload;
const withToggledColumn = state.map((col) => {
if (col.name === column) {
return {
...col,
active: !col.active,
};
}
return col;
});
return buildColumns({
columns: withToggledColumn,
collection,
t,
});
}
case 'move': {
const {
fromIndex,
toIndex,
t,
collection,
} = action.payload;
const withMovedColumn = [...state];
const [columnToMove] = withMovedColumn.splice(fromIndex, 1);
withMovedColumn.splice(toIndex, 0, columnToMove);
return buildColumns({
columns: withMovedColumn,
collection,
t,
});
}
case 'set': {
const {
columns,
t,
collection,
} = action.payload;
return buildColumns({
columns,
collection,
t,
});
}
default:
return state;
}
};

View File

@@ -1,4 +1,4 @@
import { Field, fieldHasSubFields, fieldAffectsData, tabHasName } 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) {

View File

@@ -0,0 +1,167 @@
import React, { useCallback, useEffect, useReducer, createContext, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { usePreferences } from '../../utilities/Preferences';
import { ListPreferences } from '../../views/collections/List/types';
import { Column } from '../Table/types';
import buildColumns from './buildColumns';
import { Action, columnReducer } from './columnReducer';
import getInitialColumnState from './getInitialColumns';
import { Props as CellProps } from '../../views/collections/List/Cell/types';
export interface ITableColumns {
columns: Column[]
dispatchTableColumns: React.Dispatch<Action>
setActiveColumns: (columns: string[]) => void
moveColumn:(args: {
fromIndex: number
toIndex: number
}) => void
toggleColumn: (column: string) => void
}
export const TableColumnContext = createContext<ITableColumns>({} as ITableColumns);
export const useTableColumns = (): ITableColumns => useContext(TableColumnContext);
export const TableColumnsProvider: React.FC<{
children: React.ReactNode
collection: SanitizedCollectionConfig
cellProps?: Partial<CellProps>[]
}> = ({
children,
cellProps,
collection,
collection: {
fields,
admin: {
useAsTitle,
defaultColumns,
},
},
}) => {
const { t } = useTranslation('general');
const preferenceKey = `${collection.slug}-list`;
const { getPreference, setPreference } = usePreferences();
const [tableColumns, dispatchTableColumns] = useReducer(columnReducer, {}, () => {
const initialColumns = getInitialColumnState(fields, useAsTitle, defaultColumns);
return buildColumns({
collection,
columns: initialColumns.map((column) => ({
accessor: column,
active: true,
})),
cellProps,
t,
});
});
// /////////////////////////////////////
// Fetch preferences on first load
// /////////////////////////////////////
useEffect(() => {
const makeRequest = async () => {
const currentPreferences = await getPreference<ListPreferences>(preferenceKey);
if (currentPreferences?.columns) {
dispatchTableColumns({
type: 'set',
payload: {
columns: currentPreferences.columns.map((column) => {
// 'string' is for backwards compatibility
// the preference used to be stored as an array of strings
if (typeof column === 'string') {
return {
accessor: column,
active: true,
};
}
return column;
}),
t,
collection,
},
});
}
};
makeRequest();
}, [collection, getPreference, preferenceKey, t]);
// /////////////////////////////////////
// Set preferences on change
// /////////////////////////////////////
useEffect(() => {
(async () => {
const currentPreferences = await getPreference<ListPreferences>(preferenceKey);
const newPreferences = {
...currentPreferences,
columns: tableColumns.map((c) => ({
accessor: c.accessor,
active: c.active,
})),
};
setPreference(preferenceKey, newPreferences);
})();
}, [preferenceKey, setPreference, fields, tableColumns, getPreference]);
const setActiveColumns = useCallback((columns: string[]) => {
dispatchTableColumns({
type: 'set',
payload: {
collection,
columns: columns.map((column) => ({
accessor: column,
active: true,
})),
t,
// onSelect,
},
});
}, [collection, t]);
const moveColumn = useCallback((args: {
fromIndex: number
toIndex: number
}) => {
const { fromIndex, toIndex } = args;
dispatchTableColumns({
type: 'move',
payload: {
fromIndex,
toIndex,
collection,
t,
},
});
}, [collection, t]);
const toggleColumn = useCallback((column: string) => {
dispatchTableColumns({
type: 'toggle',
payload: {
column,
collection,
t,
},
});
}, [collection, t]);
return (
<TableColumnContext.Provider
value={{
columns: tableColumns,
dispatchTableColumns,
setActiveColumns,
moveColumn,
toggleColumn,
}}
>
{children}
</TableColumnContext.Provider>
);
};

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useReducer } from 'react';
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../../utilities/Auth';
import withCondition from '../../withCondition';
@@ -27,6 +26,8 @@ import { getTranslation } from '../../../../../utilities/getTranslation';
import { createNestedFieldPath } from '../../Form/createNestedFieldPath';
import { useConfig } from '../../../utilities/Config';
import { NullifyLocaleField } from '../../NullifyField';
import DraggableSortable from '../../../elements/DraggableSortable';
import DraggableSortableItem from '../../../elements/DraggableSortable/DraggableSortableItem';
import './index.scss';
@@ -142,13 +143,6 @@ const ArrayFieldType: React.FC<Props> = (props) => {
setModified(true);
}, [dispatchRows, dispatchFields, path, setModified]);
const onDragEnd = useCallback((result) => {
if (!result.destination) return;
const sourceIndex = result.source.index;
const destinationIndex = result.destination.index;
moveRow(sourceIndex, destinationIndex);
}, [moveRow]);
const setCollapse = useCallback(
async (rowID: string, collapsed: boolean) => {
dispatchRows({ type: 'SET_COLLAPSE', id: rowID, collapsed });
@@ -224,161 +218,160 @@ const ArrayFieldType: React.FC<Props> = (props) => {
if (!rows) return null;
return (
<DragDropContext onDragEnd={onDragEnd}>
<div
id={`field-${path.replace(/\./gi, '__')}`}
className={classes}
>
<div className={`${baseClass}__error-wrap`}>
<Error
showError={showError}
message={errorMessage}
/>
</div>
<header className={`${baseClass}__header`}>
<div className={`${baseClass}__header-wrap`}>
<h3>{getTranslation(label || name, i18n)}</h3>
<ul className={`${baseClass}__header-actions`}>
<li>
<button
type="button"
onClick={() => toggleCollapseAll(true)}
className={`${baseClass}__header-action`}
>
{t('collapseAll')}
</button>
</li>
<li>
<button
type="button"
onClick={() => toggleCollapseAll(false)}
className={`${baseClass}__header-action`}
>
{t('showAll')}
</button>
</li>
</ul>
</div>
<FieldDescription
className={`field-description-${path.replace(/\./gi, '__')}`}
value={value}
description={description}
/>
</header>
<NullifyLocaleField
path={path}
fieldValue={value}
<div
id={`field-${path.replace(/\./gi, '__')}`}
className={classes}
>
<div className={`${baseClass}__error-wrap`}>
<Error
showError={showError}
message={errorMessage}
/>
<Droppable droppableId="array-drop">
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
>
{rows.length > 0 && rows.map((row, i) => {
const rowNumber = i + 1;
const fallbackLabel = `${getTranslation(labels.singular, i18n)} ${String(rowNumber).padStart(2, '0')}`;
return (
<Draggable
key={row.id}
draggableId={row.id}
index={i}
isDragDisabled={readOnly}
>
{(providedDrag) => (
<div
id={`${path}-row-${i}`}
ref={providedDrag.innerRef}
{...providedDrag.draggableProps}
>
<Collapsible
collapsed={row.collapsed}
onToggle={(collapsed) => setCollapse(row.id, collapsed)}
className={`${baseClass}__row`}
key={row.id}
dragHandleProps={providedDrag.dragHandleProps}
header={(
<RowLabel
path={`${path}.${i}`}
label={CustomRowLabel || fallbackLabel}
rowNumber={rowNumber}
/>
)}
actions={!readOnly ? (
<ArrayAction
rowCount={rows.length}
duplicateRow={duplicateRow}
addRow={addRow}
moveRow={moveRow}
removeRow={removeRow}
index={i}
/>
) : undefined}
>
<HiddenInput
name={`${path}.${i}.id`}
value={row.id}
/>
<RenderFields
className={`${baseClass}__fields`}
forceRender
readOnly={readOnly}
fieldTypes={fieldTypes}
permissions={permissions?.fields}
indexPath={indexPath}
fieldSchema={fields.map((field) => ({
...field,
path: createNestedFieldPath(`${path}.${i}`, field),
}))}
/>
</Collapsible>
</div>
)}
</Draggable>
);
})}
{!checkSkipValidation(value) && (
<React.Fragment>
{(rows.length < minRows || (required && rows.length === 0)) && (
<Banner type="error">
{t('validation:requiresAtLeast', {
count: minRows,
label: getTranslation(minRows ? labels.plural : labels.singular, i18n) || t(minRows > 1 ? 'general:row' : 'general:rows'),
})}
</Banner>
)}
{(rows.length === 0 && readOnly) && (
<Banner>
{t('validation:fieldHasNo', { label: getTranslation(labels.plural, i18n) })}
</Banner>
)}
</React.Fragment>
)}
{provided.placeholder}
</div>
)}
</Droppable>
{(!readOnly && !hasMaxRows) && (
<div className={`${baseClass}__add-button-wrap`}>
<Button
onClick={() => addRow(value as number)}
buttonStyle="icon-label"
icon="plus"
iconStyle="with-border"
iconPosition="left"
>
{t('addLabel', { label: getTranslation(labels.singular, i18n) })}
</Button>
</div>
)}
</div>
</DragDropContext>
<header className={`${baseClass}__header`}>
<div className={`${baseClass}__header-wrap`}>
<h3>{getTranslation(label || name, i18n)}</h3>
<ul className={`${baseClass}__header-actions`}>
<li>
<button
type="button"
onClick={() => toggleCollapseAll(true)}
className={`${baseClass}__header-action`}
>
{t('collapseAll')}
</button>
</li>
<li>
<button
type="button"
onClick={() => toggleCollapseAll(false)}
className={`${baseClass}__header-action`}
>
{t('showAll')}
</button>
</li>
</ul>
</div>
<FieldDescription
className={`field-description-${path.replace(/\./gi, '__')}`}
value={value}
description={description}
/>
</header>
<NullifyLocaleField
path={path}
fieldValue={value}
/>
<DraggableSortable
ids={rows.map((row) => row.id)}
onDragEnd={({ moveFromIndex, moveToIndex }) => moveRow(moveFromIndex, moveToIndex)}
>
{rows.length > 0 && rows.map((row, i) => {
const rowNumber = i + 1;
const fallbackLabel = `${getTranslation(labels.singular, i18n)} ${String(rowNumber).padStart(2, '0')}`;
return (
<DraggableSortableItem
key={row.id}
id={row.id}
disabled={readOnly}
>
{({ setNodeRef, transform, attributes, listeners }) => (
<div
id={`${path}-row-${i}`}
key={`${path}-row-${i}`}
ref={setNodeRef}
style={{
transform,
}}
>
<Collapsible
collapsed={row.collapsed}
onToggle={(collapsed) => setCollapse(row.id, collapsed)}
className={`${baseClass}__row`}
key={row.id}
dragHandleProps={{
id: row.id,
attributes,
listeners,
}}
header={(
<RowLabel
path={`${path}.${i}`}
label={CustomRowLabel || fallbackLabel}
rowNumber={rowNumber}
/>
)}
actions={!readOnly ? (
<ArrayAction
rowCount={rows.length}
duplicateRow={duplicateRow}
addRow={addRow}
moveRow={moveRow}
removeRow={removeRow}
index={i}
/>
) : undefined}
>
<HiddenInput
name={`${path}.${i}.id`}
value={row.id}
/>
<RenderFields
className={`${baseClass}__fields`}
forceRender
readOnly={readOnly}
fieldTypes={fieldTypes}
permissions={permissions?.fields}
indexPath={indexPath}
fieldSchema={fields.map((field) => ({
...field,
path: createNestedFieldPath(`${path}.${i}`, field),
}))}
/>
</Collapsible>
</div>
)}
</DraggableSortableItem>
);
})}
{!checkSkipValidation(value) && (
<React.Fragment>
{(rows.length < minRows || (required && rows.length === 0)) && (
<Banner type="error">
{t('validation:requiresAtLeast', {
count: minRows,
label: getTranslation(minRows ? labels.plural : labels.singular, i18n) || t(minRows > 1 ? 'general:row' : 'general:rows'),
})}
</Banner>
)}
{(rows.length === 0 && readOnly) && (
<Banner>
{t('validation:fieldHasNo', { label: getTranslation(labels.plural, i18n) })}
</Banner>
)}
</React.Fragment>
)}
</DraggableSortable>
{(!readOnly && !hasMaxRows) && (
<div className={`${baseClass}__add-button-wrap`}>
<Button
onClick={() => addRow(value as number)}
buttonStyle="icon-label"
icon="plus"
iconStyle="with-border"
iconPosition="left"
>
{t('addLabel', { label: getTranslation(labels.singular, i18n) })}
</Button>
</div>
)}
</div>
);
};

View File

@@ -1,5 +1,4 @@
import React, { Fragment, useCallback, useEffect, useReducer } from 'react';
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../../utilities/Auth';
import { usePreferences } from '../../../utilities/Preferences';
@@ -27,10 +26,12 @@ import { getTranslation } from '../../../../../utilities/getTranslation';
import { NullifyLocaleField } from '../../NullifyField';
import { useConfig } from '../../../utilities/Config';
import { createNestedFieldPath } from '../../Form/createNestedFieldPath';
import { DrawerToggler } from '../../../elements/Drawer';
import DraggableSortable from '../../../elements/DraggableSortable';
import DraggableSortableItem from '../../../elements/DraggableSortable/DraggableSortableItem';
import { useDrawerSlug } from '../../../elements/Drawer/useDrawerSlug';
import Button from '../../../elements/Button';
import { RowActions } from './RowActions';
import { DrawerToggler } from '../../../elements/Drawer';
import './index.scss';
@@ -141,13 +142,6 @@ const BlocksField: React.FC<Props> = (props) => {
setModified(true);
}, [dispatchRows, dispatchFields, path, setModified]);
const onDragEnd = useCallback((result) => {
if (!result.destination) return;
const sourceIndex = result.source.index;
const destinationIndex = result.destination.index;
moveRow(sourceIndex, destinationIndex);
}, [moveRow]);
const setCollapse = useCallback(async (rowID: string, collapsed: boolean) => {
dispatchRows({ type: 'SET_COLLAPSE', id: rowID, collapsed });
@@ -221,190 +215,187 @@ const BlocksField: React.FC<Props> = (props) => {
if (!rows) return null;
return (
<DragDropContext onDragEnd={onDragEnd}>
<div
id={`field-${path.replace(/\./gi, '__')}`}
className={classes}
>
<div className={`${baseClass}__error-wrap`}>
<Error
showError={showError}
message={errorMessage}
/>
</div>
<header className={`${baseClass}__header`}>
<div className={`${baseClass}__header-wrap`}>
<h3>{getTranslation(label || name, i18n)}</h3>
<ul className={`${baseClass}__header-actions`}>
<li>
<button
type="button"
onClick={() => toggleCollapseAll(true)}
className={`${baseClass}__header-action`}
>
{t('collapseAll')}
</button>
</li>
<li>
<button
type="button"
onClick={() => toggleCollapseAll(false)}
className={`${baseClass}__header-action`}
>
{t('showAll')}
</button>
</li>
</ul>
</div>
<FieldDescription
value={value}
description={description}
/>
</header>
<NullifyLocaleField
path={path}
fieldValue={value}
<div
id={`field-${path.replace(/\./gi, '__')}`}
className={classes}
>
<div className={`${baseClass}__error-wrap`}>
<Error
showError={showError}
message={errorMessage}
/>
<Droppable
droppableId="blocks-drop"
isDropDisabled={readOnly}
>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
>
{rows.length > 0 && rows.map((row, i) => {
const { blockType } = row;
const blockToRender = blocks.find((block) => block.slug === blockType);
</div>
<header className={`${baseClass}__header`}>
<div className={`${baseClass}__header-wrap`}>
<h3>{getTranslation(label || name, i18n)}</h3>
<ul className={`${baseClass}__header-actions`}>
<li>
<button
type="button"
onClick={() => toggleCollapseAll(true)}
className={`${baseClass}__header-action`}
>
{t('collapseAll')}
</button>
</li>
<li>
<button
type="button"
onClick={() => toggleCollapseAll(false)}
className={`${baseClass}__header-action`}
>
{t('showAll')}
</button>
</li>
</ul>
</div>
<FieldDescription
value={value}
description={description}
/>
</header>
const rowNumber = i + 1;
<NullifyLocaleField
path={path}
fieldValue={value}
/>
if (blockToRender) {
return (
<Draggable
<DraggableSortable
ids={rows.map((row) => row.id)}
onDragEnd={({ moveFromIndex, moveToIndex }) => moveRow(moveFromIndex, moveToIndex)}
>
{rows.length > 0 && rows.map((row, i) => {
const { blockType } = row;
const blockToRender = blocks.find((block) => block.slug === blockType);
const rowNumber = i + 1;
if (blockToRender) {
return (
<DraggableSortableItem
key={row.id}
id={row.id}
disabled={readOnly}
>
{({ setNodeRef, transform, attributes, listeners }) => (
<div
id={`${path}-row-${i}`}
ref={setNodeRef}
style={{
transform,
}}
>
<Collapsible
collapsed={row.collapsed}
onToggle={(collapsed) => setCollapse(row.id, collapsed)}
className={`${baseClass}__row`}
key={row.id}
draggableId={row.id}
index={i}
isDragDisabled={readOnly}
>
{(providedDrag) => (
<div
id={`${path}-row-${i}`}
ref={providedDrag.innerRef}
{...providedDrag.draggableProps}
>
<Collapsible
collapsed={row.collapsed}
onToggle={(collapsed) => setCollapse(row.id, collapsed)}
className={`${baseClass}__row`}
key={row.id}
dragHandleProps={providedDrag.dragHandleProps}
header={(
<div className={`${baseClass}__block-header`}>
<span className={`${baseClass}__block-number`}>
{rowNumber >= 10 ? rowNumber : `0${rowNumber}`}
</span>
<Pill
pillStyle="white"
className={`${baseClass}__block-pill ${baseClass}__block-pill-${blockType}`}
>
{getTranslation(blockToRender.labels.singular, i18n)}
</Pill>
<SectionTitle
path={`${path}.${i}.blockName`}
readOnly={readOnly}
/>
</div>
)}
actions={!readOnly ? (
<RowActions
addRow={addRow}
removeRow={removeRow}
duplicateRow={duplicateRow}
moveRow={moveRow}
rows={rows}
blockType={blockType}
blocks={blocks}
labels={labels}
rowIndex={i}
/>
) : undefined}
dragHandleProps={{
id: row.id,
attributes,
listeners,
}}
header={(
<div className={`${baseClass}__block-header`}>
<span className={`${baseClass}__block-number`}>
{rowNumber >= 10 ? rowNumber : `0${rowNumber}`}
</span>
<Pill
pillStyle="white"
className={`${baseClass}__block-pill ${baseClass}__block-pill-${blockType}`}
>
<HiddenInput
name={`${path}.${i}.id`}
value={row.id}
/>
<RenderFields
className={`${baseClass}__fields`}
forceRender
readOnly={readOnly}
fieldTypes={fieldTypes}
permissions={permissions?.fields}
fieldSchema={blockToRender.fields.map((field) => ({
...field,
path: createNestedFieldPath(`${path}.${i}`, field),
}))}
indexPath={indexPath}
/>
</Collapsible>
{getTranslation(blockToRender.labels.singular, i18n)}
</Pill>
<SectionTitle
path={`${path}.${i}.blockName`}
readOnly={readOnly}
/>
</div>
)}
</Draggable>
);
}
actions={!readOnly ? (
<RowActions
addRow={addRow}
removeRow={removeRow}
duplicateRow={duplicateRow}
moveRow={moveRow}
rows={rows}
blockType={blockType}
blocks={blocks}
labels={labels}
rowIndex={i}
/>
) : undefined}
>
<HiddenInput
name={`${path}.${i}.id`}
value={row.id}
/>
<RenderFields
className={`${baseClass}__fields`}
forceRender
readOnly={readOnly}
fieldTypes={fieldTypes}
permissions={permissions?.fields}
fieldSchema={blockToRender.fields.map((field) => ({
...field,
path: createNestedFieldPath(`${path}.${i}`, field),
}))}
indexPath={indexPath}
/>
return null;
})}
{!checkSkipValidation(value) && (
<React.Fragment>
{(rows.length < minRows || (required && rows.length === 0)) && (
<Banner type="error">
{t('validation:requiresAtLeast', {
count: minRows,
label: getTranslation(minRows === 1 || typeof minRows === 'undefined' ? labels.singular : labels.plural, i18n),
})}
</Banner>
)}
{(rows.length === 0 && readOnly) && (
<Banner>
{t('validation:fieldHasNo', { label: getTranslation(labels.plural, i18n) })}
</Banner>
)}
</React.Fragment>
)}
{provided.placeholder}
</div>
)}
</Droppable>
{(!readOnly && !hasMaxRows) && (
<Fragment>
<DrawerToggler
slug={drawerSlug}
className={`${baseClass}__drawer-toggler`}
>
<Button
el="span"
icon="plus"
buttonStyle="icon-label"
iconPosition="left"
iconStyle="with-border"
>
{t('addLabel', { label: getTranslation(labels.singular, i18n) })}
</Button>
</DrawerToggler>
<BlocksDrawer
drawerSlug={drawerSlug}
blocks={blocks}
addRow={addRow}
addRowIndex={value}
labels={labels}
/>
</Fragment>
</Collapsible>
</div>
)}
</DraggableSortableItem>
);
}
return null;
})}
{!checkSkipValidation(value) && (
<React.Fragment>
{(rows.length < minRows || (required && rows.length === 0)) && (
<Banner type="error">
{t('validation:requiresAtLeast', {
count: minRows,
label: getTranslation(minRows === 1 || typeof minRows === 'undefined' ? labels.singular : labels.plural, i18n),
})}
</Banner>
)}
{(rows.length === 0 && readOnly) && (
<Banner>
{t('validation:fieldHasNo', { label: getTranslation(labels.plural, i18n) })}
</Banner>
)}
</React.Fragment>
)}
</div>
</DragDropContext>
</DraggableSortable>
{(!readOnly && !hasMaxRows) && (
<Fragment>
<DrawerToggler
slug={drawerSlug}
className={`${baseClass}__drawer-toggler`}
>
<Button
el="span"
icon="plus"
buttonStyle="icon-label"
iconPosition="left"
iconStyle="with-border"
>
{t('addLabel', { label: getTranslation(labels.singular, i18n) })}
</Button>
</DrawerToggler>
<BlocksDrawer
drawerSlug={drawerSlug}
blocks={blocks}
addRow={addRow}
addRowIndex={value}
labels={labels}
/>
</Fragment>
)}
</div>
);
};

View File

@@ -36,7 +36,8 @@ export const PreferencesProvider: React.FC<{children?: React.ReactNode}> = ({ ch
}, [user]);
const getPreference = useCallback(async <T = any>(key: string): Promise<T> => {
if (typeof preferencesRef.current[key] !== 'undefined') return preferencesRef.current[key];
const prefs = preferencesRef.current;
if (typeof prefs[key] !== 'undefined') return prefs[key];
const promise = new Promise((resolve: (value: T) => void) => {
(async () => {
const request = await requests.get(`${serverURL}${api}/_preferences/${key}`, {
@@ -53,7 +54,7 @@ export const PreferencesProvider: React.FC<{children?: React.ReactNode}> = ({ ch
resolve(value);
})();
});
preferencesRef.current[key] = promise;
prefs[key] = promise;
return promise;
}, [i18n.language, api, preferencesRef, serverURL]);

View File

@@ -40,9 +40,16 @@ const TextCell: React.FC<{children?: React.ReactNode}> = ({ children }) => (
</span>
);
export const getColumns = (collection: SanitizedCollectionConfig, global: SanitizedGlobalConfig, t: TFunction): Column[] => [
export const buildColumns = (
collection: SanitizedCollectionConfig,
global: SanitizedGlobalConfig,
t: TFunction,
): Column[] => [
{
accessor: 'updatedAt',
active: true,
label: '',
name: '',
components: {
Heading: (
<SortColumn
@@ -62,6 +69,9 @@ export const getColumns = (collection: SanitizedCollectionConfig, global: Saniti
},
{
accessor: 'id',
active: true,
label: '',
name: '',
components: {
Heading: (
<SortColumn
@@ -75,6 +85,9 @@ export const getColumns = (collection: SanitizedCollectionConfig, global: Saniti
},
{
accessor: 'autosave',
active: true,
label: '',
name: '',
components: {
Heading: (
<SortColumn

View File

@@ -10,8 +10,7 @@ import { StepNavItem } from '../../elements/StepNav/types';
import Meta from '../../utilities/Meta';
import { Props } from './types';
import IDLabel from '../../elements/IDLabel';
import { getColumns } from './columns';
import Table from '../../elements/Table';
import { Table } from '../../elements/Table';
import Paginator from '../../elements/Paginator';
import PerPage from '../../elements/PerPage';
import { useSearchParams } from '../../utilities/SearchParams';
@@ -27,7 +26,6 @@ const Versions: React.FC<Props> = ({ collection, global }) => {
const { setStepNav } = useStepNav();
const { params: { id } } = useRouteMatch<{ id: string }>();
const { t, i18n } = useTranslation('version');
const [tableColumns] = useState(() => getColumns(collection, global, t));
const [fetchURL, setFetchURL] = useState('');
const { page, sort, limit } = useSearchParams();
@@ -183,10 +181,7 @@ const Versions: React.FC<Props> = ({ collection, global }) => {
{versionsData?.totalDocs > 0 && (
<React.Fragment>
<Table
data={versionsData?.docs}
columns={tableColumns}
/>
<Table data={versionsData?.docs} />
<div className={`${baseClass}__page-controls`}>
<Paginator
limit={versionsData.limit}

View File

@@ -52,7 +52,9 @@ const EditView: React.FC<IndexProps> = (props) => {
{ initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } },
);
const onSave = useCallback(async (json: any) => {
const onSave = useCallback(async (json: {
doc
}) => {
getVersions();
getDocPermissions();
setUpdatedAt(json?.doc?.updatedAt);

View File

@@ -1,8 +1,8 @@
import { Field } from '../../../../../../fields/config/types';
import { FieldAffectingData, UIField } from '../../../../../../fields/config/types';
import { SanitizedCollectionConfig } from '../../../../../../collections/config/types';
export type Props = {
field: Field
field: UIField | FieldAffectingData
colIndex: number
collection: SanitizedCollectionConfig
cellData: unknown

View File

@@ -8,7 +8,7 @@ import Paginator from '../../../elements/Paginator';
import ListControls from '../../../elements/ListControls';
import Pill from '../../../elements/Pill';
import Button from '../../../elements/Button';
import Table from '../../../elements/Table';
import { Table } from '../../../elements/Table';
import Meta from '../../../utilities/Meta';
import { Props } from './types';
import ViewDescription from '../../../elements/ViewDescription';
@@ -39,9 +39,6 @@ const DefaultList: React.FC<Props> = (props) => {
data,
newDocumentURL,
limit,
tableColumns,
columnNames,
setColumns,
hasCreatePermission,
disableEyebrow,
modifySearchParams,
@@ -89,15 +86,12 @@ const DefaultList: React.FC<Props> = (props) => {
</header>
<ListControls
collection={collection}
columns={columnNames}
setColumns={setColumns}
enableColumns={Boolean(!upload)}
enableSort={Boolean(upload)}
modifySearchQuery={modifySearchParams}
handleSortChange={handleSortChange}
handleWhereChange={handleWhereChange}
/>
{!data.docs && (
<StaggeredShimmers
className={[
@@ -108,15 +102,11 @@ const DefaultList: React.FC<Props> = (props) => {
width={upload ? 'unset' : '100%'}
/>
)}
{(data.docs && data.docs.length > 0) && (
<React.Fragment>
{!upload && (
<RelationshipProvider>
<Table
data={data.docs}
columns={tableColumns}
/>
<Table data={data.docs} />
</RelationshipProvider>
)}
{upload && (

View File

@@ -15,7 +15,7 @@ const buildColumns = ({
cellProps,
}: {
collection: SanitizedCollectionConfig,
columns: string[],
columns: Pick<Column, 'accessor' | 'active'>[],
t: TFunction,
cellProps?: Partial<CellProps>[]
}): Column[] => {
@@ -36,51 +36,56 @@ const buildColumns = ({
type: 'date',
label: t('createdAt'),
},
], true);
]);
return (columns || []).reduce((cols, col, colIndex) => {
let field = null;
// sort the fields to the order of activeColumns
const sortedFields = flattenedFields.sort((a, b) => {
const aIndex = columns.findIndex((column) => column.accessor === a.name);
const bIndex = columns.findIndex((column) => column.accessor === b.name);
if (aIndex === -1 && bIndex === -1) return 0;
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
flattenedFields.forEach((fieldToCheck) => {
if (fieldToCheck.name === col) {
field = fieldToCheck;
}
});
const firstActiveColumn = sortedFields.find((field) => columns.find((column) => column.accessor === field.name)?.active);
if (field) {
return [
...cols,
{
accessor: field.name,
components: {
Heading: (
<SortColumn
label={field.label || field.name}
name={field.name}
disable={(field.disableSort || fieldIsPresentationalOnly(field)) || undefined}
/>
),
renderCell: (rowData, cellData) => {
return (
<Cell
key={JSON.stringify(cellData)}
field={field}
colIndex={colIndex}
collection={collection}
rowData={rowData}
cellData={cellData}
link={colIndex === 0}
{...cellProps?.[colIndex] || {}}
/>
);
},
},
const cols: Column[] = sortedFields.map((field, colIndex) => {
const isActive = columns.find((column) => column.accessor === field.name)?.active || false;
const isFirstActive = firstActiveColumn?.name === field.name;
return {
active: isActive,
accessor: field.name,
name: field.name,
label: field.label,
components: {
Heading: (
<SortColumn
label={field.label || field.name}
name={field.name}
disable={(('disableSort' in field && Boolean(field.disableSort)) || fieldIsPresentationalOnly(field)) || undefined}
/>
),
renderCell: (rowData, cellData) => {
return (
<Cell
key={JSON.stringify(cellData)}
field={field}
colIndex={colIndex}
collection={collection}
rowData={rowData}
cellData={cellData}
link={isFirstActive}
{...cellProps?.[colIndex] || {}}
/>
);
},
];
}
},
};
});
return cols;
}, []);
return cols;
};
export default buildColumns;

View File

@@ -0,0 +1,96 @@
import { TFunction } from 'react-i18next';
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
import { Column } from '../../../elements/Table/types';
import buildColumns from './buildColumns';
type TOGGLE = {
type: 'toggle',
payload: {
column: string
t: TFunction
collection: SanitizedCollectionConfig
}
}
type SET = {
type: 'set',
payload: {
columns: Pick<Column, 'accessor' | 'active'>[]
t: TFunction
collection: SanitizedCollectionConfig
}
}
type MOVE = {
type: 'move',
payload: {
fromIndex: number
toIndex: number
t: TFunction
collection: SanitizedCollectionConfig
}
}
export type Action = TOGGLE | SET | MOVE;
export const columnReducer = (state: Column[], action: Action): Column[] => {
switch (action.type) {
case 'toggle': {
const {
column,
t,
collection,
} = action.payload;
const withToggledColumn = state.map((col) => {
if (col.name === column) {
return {
...col,
active: !col.active,
};
}
return col;
});
return buildColumns({
columns: withToggledColumn,
collection,
t,
});
}
case 'move': {
const {
fromIndex,
toIndex,
t,
collection,
} = action.payload;
const withMovedColumn = [...state];
const [columnToMove] = withMovedColumn.splice(fromIndex, 1);
withMovedColumn.splice(toIndex, 0, columnToMove);
return buildColumns({
columns: withMovedColumn,
collection,
t,
});
}
case 'set': {
const {
columns,
t,
collection,
} = action.payload;
return buildColumns({
columns,
collection,
t,
});
}
default:
return state;
}
};

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import queryString from 'qs';
import { useTranslation } from 'react-i18next';
@@ -9,13 +9,10 @@ import DefaultList from './Default';
import RenderCustomComponent from '../../../utilities/RenderCustomComponent';
import { useStepNav } from '../../../elements/StepNav';
import formatFields from './formatFields';
import buildColumns from './buildColumns';
import { ListIndexProps, ListPreferences } from './types';
import { usePreferences } from '../../../utilities/Preferences';
import { useSearchParams } from '../../../utilities/SearchParams';
import { Column } from '../../../elements/Table/types';
import { Field } from '../../../../../fields/config/types';
import getInitialColumns from './getInitialColumns';
const ListView: React.FC<ListIndexProps> = (props) => {
const {
@@ -26,8 +23,6 @@ const ListView: React.FC<ListIndexProps> = (props) => {
plural,
},
admin: {
useAsTitle,
defaultColumns,
pagination: {
defaultLimit,
},
@@ -48,22 +43,13 @@ const ListView: React.FC<ListIndexProps> = (props) => {
const { page, sort, limit, where } = useSearchParams();
const history = useHistory();
const { t } = useTranslation('general');
const [fetchURL, setFetchURL] = useState<string>('');
const [fields] = useState<Field[]>(() => formatFields(collection, t));
const [tableColumns, setTableColumns] = useState<Column[]>(() => {
const initialColumns = getInitialColumns(fields, useAsTitle, defaultColumns);
return buildColumns({ collection, columns: initialColumns, t });
});
const collectionPermissions = permissions?.collections?.[slug];
const hasCreatePermission = collectionPermissions?.create?.permission;
const newDocumentURL = `${admin}/collections/${slug}/create`;
const [{ data }, { setParams: setFetchParams }] = usePayloadAPI(fetchURL, { initialParams: { page: 1 } });
const activeColumnNames = tableColumns.map(({ accessor }) => accessor);
const stringifiedActiveColumns = JSON.stringify(activeColumnNames);
useEffect(() => {
setStepNav([
{
@@ -96,7 +82,7 @@ const ListView: React.FC<ListIndexProps> = (props) => {
setFetchURL(`${serverURL}${api}/${slug}`);
setFetchParams(params);
}, [setFetchParams, page, sort, where, collection, getPreference, limit, serverURL, api, slug]);
}, [setFetchParams, page, sort, where, collection, limit, serverURL, api, slug]);
// /////////////////////////////////////
// Fetch preferences on first load
@@ -105,9 +91,6 @@ const ListView: React.FC<ListIndexProps> = (props) => {
useEffect(() => {
(async () => {
const currentPreferences = await getPreference<ListPreferences>(preferenceKey);
if (currentPreferences?.columns) {
setTableColumns(buildColumns({ collection, columns: currentPreferences?.columns, t }));
}
const params = queryString.parse(history.location.search, { ignoreQueryPrefix: true, depth: 0 });
@@ -128,23 +111,22 @@ const ListView: React.FC<ListIndexProps> = (props) => {
}, [collection, getPreference, preferenceKey, history, t, defaultLimit]);
// /////////////////////////////////////
// When any preference-enabled values are updated,
// Set preferences
// Set preferences on change
// /////////////////////////////////////
useEffect(() => {
const newPreferences = {
limit,
sort,
columns: JSON.parse(stringifiedActiveColumns),
};
(async () => {
const currentPreferences = await getPreference<ListPreferences>(preferenceKey);
setPreference(preferenceKey, newPreferences);
}, [sort, limit, stringifiedActiveColumns, preferenceKey, setPreference]);
const newPreferences = {
...currentPreferences,
limit,
sort,
};
const setActiveColumns = useCallback((columns: string[]) => {
setTableColumns(buildColumns({ collection, columns, t }));
}, [collection, t]);
setPreference(preferenceKey, newPreferences);
})();
}, [sort, limit, preferenceKey, setPreference, getPreference]);
return (
<RenderCustomComponent
@@ -155,9 +137,6 @@ const ListView: React.FC<ListIndexProps> = (props) => {
newDocumentURL,
hasCreatePermission,
data,
tableColumns,
columnNames: activeColumnNames,
setColumns: setActiveColumns,
limit: limit || defaultLimit,
}}
/>

View File

@@ -1,6 +1,5 @@
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
import { PaginatedDocs } from '../../../../../mongoose/types';
import { Column } from '../../../elements/Table/types';
import { Props as ListControlsProps } from '../../../elements/ListControls/types';
import { Props as PerPageProps } from '../../../elements/PerPage';
import { Props as PaginatorProps } from '../../../elements/Paginator/types';
@@ -11,9 +10,7 @@ export type Props = {
newDocumentURL: string
setListControls: (controls: unknown) => void
setSort: (sort: string) => void
tableColumns: Column[]
columnNames: string[]
setColumns: (columns: string[]) => void
toggleColumn: (column: string) => void
hasCreatePermission: boolean
setLimit: (limit: number) => void
limit: number
@@ -34,7 +31,10 @@ export type ListIndexProps = {
}
export type ListPreferences = {
columns: string[]
columns: {
accessor: string
active: boolean
}[]
limit: number
sort: number
}