import {
AddIcon,
CheckmarkIcon,
ChevronLeftIcon,
ChevronRightIcon,
FolderIcon,
SyncIcon,
} from '@sanity/icons';
import {
Box,
Button,
Card,
Checkbox,
Flex,
Label,
Select,
Text,
ThemeProvider,
ToastProvider,
Tooltip,
} from '@sanity/ui';
import { buildTheme } from '@sanity/ui/theme';
import classNames from 'classnames';
import get from 'lodash.get';
import pluralize, { singular } from 'pluralize';
import { useMemo, useRef } from 'react';
import { Preview } from 'sanity';
import { ListItem, usePaneRouter } from 'sanity/structure';
import BulkActionsMenu from './BulkActionsMenu';
import ColumnSelector from './ColumnSelector';
import SearchField from './SearchField';
import {
Options,
defaultDatetimeFields,
orderColumnDefault,
rowsPerPage,
} from './constants';
import {
BulkActionsTableProvider,
useBulkActionsTableContext,
} from './context';
import createEmitter from './createEmitter';
import {
SelectableField,
getSelectableFields,
} from './helpers/getSelectableFields';
import {
COLUMN_SELECTOR_WIDTH,
CheckboxCellTd,
CheckboxCellTh,
CheckboxFacade,
ColumnSelectBodyCell,
ColumnSelectHeadCell,
Container,
HiddenCheckbox,
LoadingOverlay,
LoadingSpinner,
StatusBadge,
Table as StyledTable,
TableWrapper,
} from './styles';
import Cell from './table/Cell';
import { TableHeadCell } from './table/TableHeadCell';
import {
CellPrimitive,
RowPrimitive,
TableHeadPrimitive,
} from './table/primitives';
import { CreateBulkActionsTableConfig } from './types';
function parentHasClass(el: HTMLElement | null, className: string): boolean {
if (!el) return false;
if (el.classList.contains(className)) return true;
return parentHasClass(el.parentElement, className);
}
interface BulkActionsTableProps {
options: Options;
}
// Type for fields that can be either real SelectableField or mock fields for built-in columns
type TableField =
| SelectableField
| {
fieldPath: string;
title: string | undefined;
level: number;
sortable: boolean;
type: string;
field: { type: { name: string } };
};
const nonSchemaFields = defaultDatetimeFields.map(
({ key, title, sortable }) => ({
fieldPath: key,
title,
field: { type: { name: key } },
level: 0,
sortable,
type: key, // Add the missing type property
}),
);
const ActionRow = () => {
const { navigateIntent } = usePaneRouter();
const {
options: { type, client },
selectedIds,
setSelectedIds,
paginatedClient,
schemaType,
isSelectState,
setIsSelectState,
} = useBulkActionsTableContext();
return (
{isSelectState ? (
{selectedIds.size === 0
? 'Select items'
: `${selectedIds.size} item${selectedIds.size === 1 ? '' : 's'} selected`}
{selectedIds.size > 0 && (
{
setSelectedIds(new Set());
paginatedClient.setPage(0);
paginatedClient.refresh();
}}
/>
)}
Cancel bulk selection
}
delay={{ open: 400 }}
padding={1}
placement="bottom"
portal
>
) : (
{paginatedClient.total} Items
Bulk select
}
delay={{ open: 400 }}
padding={1}
placement="bottom"
portal
>
)}
);
};
const Table = () => {
const { navigateIntent, groupIndex, routerPanesState } = usePaneRouter();
const openedDocumentId = routerPanesState[groupIndex + 1]?.[0]?.id || null;
const {
selectedColumns,
setSelectedColumns,
setOrderColumn,
selectedIds,
setSelectedIds,
schemaType,
isSelectState,
paginatedClient,
} = useBulkActionsTableContext();
const documentsListRef = useRef(null);
const visibleNonSchemaFields = useMemo(
() =>
nonSchemaFields.filter((field) => selectedColumns.has(field.fieldPath)),
[nonSchemaFields, selectedColumns],
);
const selectableFields = useMemo(
() =>
getSelectableFields(
'fields' in schemaType ? schemaType.fields : [],
).filter((field: SelectableField) =>
selectedColumns.has(field.fieldPath),
),
[schemaType, selectedColumns],
);
const fields: TableField[] = [...visibleNonSchemaFields, ...selectableFields];
const atLeastOneSelected = paginatedClient.results.some((i) =>
selectedIds.has(i._normalizedId),
);
const allSelected = paginatedClient.results.every((i) =>
selectedIds.has(i._normalizedId),
);
return (
{isSelectState && }
{fields.map((field: TableField) => (
))}
{isSelectState && (
{
setSelectedIds((prevSet: Set) => {
const nextSet = new Set(prevSet);
if (allSelected) {
for (const result of paginatedClient.results || []) {
nextSet.delete(result._normalizedId);
}
} else {
for (const result of paginatedClient.results || []) {
nextSet.add(result._normalizedId);
}
}
return nextSet;
});
}}
/>
)}
{fields.map((field: TableField) => (
))}
{
const nextSet = new Set(selectedColumns);
if (nextSet.has(key)) {
setOrderColumn(orderColumnDefault);
}
if (nextSet.has(key)) {
nextSet.delete(key);
} else {
nextSet.add(key);
}
setSelectedColumns(nextSet);
}}
/>
{paginatedClient.results.map((item) => {
const toggleSelect = () => {
setSelectedIds((prevSet: Set) => {
const nextSet = new Set(prevSet);
if (selectedIds.has(item._normalizedId)) {
nextSet.delete(item._normalizedId);
} else {
nextSet.add(item._normalizedId);
}
return nextSet;
});
};
return (
{
// prevent the menu button from causing a navigation
if (parentHasClass(e.target as HTMLElement, 'prevent-nav')) {
return;
}
if (isSelectState) {
toggleSelect();
return;
}
navigateIntent('edit', {
id: item._id,
type: item._type,
selectedRev: item._rev,
});
}}
>
{isSelectState && (
{
e.preventDefault();
toggleSelect();
e.stopPropagation();
}}
/>
)}
{item._status === 'published_with_pending_changes'
? 'Staged changes'
: item._status}
{fields.map((field: TableField) => (
|
))}
);
})}
);
};
const LoadingState = () => {
const { paginatedClient } = useBulkActionsTableContext();
return (
);
};
const NoResultsState = () => {
const { navigateIntent } = usePaneRouter();
const {
options: { type, title },
searchValue,
paginatedClient,
} = useBulkActionsTableContext();
if (paginatedClient.loading || paginatedClient.results.length) {
return null;
}
const pluralItemName = pluralize(title || 'item', 0).toLowerCase();
const singularItemName = singular(title || 'item').toLowerCase();
return (
{searchValue.length
? `No results for "${searchValue}".`
: `No ${pluralItemName} in your collection.`}
{!searchValue.length && (
);
};
const Footer = () => {
const { pageSize, setPageSize, paginatedClient } =
useBulkActionsTableContext();
return (
);
};
const BulkActionsTableParent = (props: BulkActionsTableProps) => {
const containerRef = useRef(null);
return (
);
};
/**
* Creates a bulk actions table for managing documents in Sanity Studio
* @public
*/
function createBulkActionsTable(
config: CreateBulkActionsTableConfig,
): ListItem {
// Validate required parameters
if (!config) {
throw new Error(`
Configuration object is required.
Example: createBulkActionsTable({type: 'category', S, context})
`);
}
if (!config.type || typeof config.type !== 'string') {
throw new Error(`
'type' parameter is required and must be a string.
The type should match a document schema type in your Sanity project.
Example: createBulkActionsTable({type: 'post', S, context})
`);
}
if (!config.context) {
throw new Error(`
'context' parameter is required.
This should be the ConfigContext provided by the structure resolver.
Example: structure: (S, context) => createBulkActionsTable({type: 'post', S, context})
`);
}
if (!config.S) {
throw new Error(`
'S' (StructureBuilder) parameter is required.
This should be the StructureBuilder provided by the structure resolver.
Example: structure: (S, context) => createBulkActionsTable({type: 'post', S, context})
`);
}
// Validate optional parameters
if (config.title !== undefined && typeof config.title !== 'string') {
throw new Error(`
'title' parameter must be a string when provided.
Example: createBulkActionsTable({type: 'post', S, context, title: 'Blog Posts'})
`);
}
if (
config.apiVersion !== undefined &&
typeof config.apiVersion !== 'string'
) {
throw new Error(`
'apiVersion' parameter must be a string when provided.
Example: createBulkActionsTable({type: 'post', S, context, apiVersion: '2024-03-12'})
`);
}
const { type, context, S, title, icon, apiVersion = '2024-03-12' } = config;
const { schema, getClient } = context;
const client = getClient({ apiVersion });
const refresh = createEmitter();
return S.listItem()
.id(type)
.title(title || type)
.icon(icon || FolderIcon)
.child(
Object.assign(S.documentTypeList(type).serialize(), {
// Prevents the component from re-rendering when switching documents
__preserveInstance: true,
// Prevents the component from NOT re-rendering when switching listItems
key: type,
type: 'component',
options: { type, client, schema, refresh, title },
component: BulkActionsTableParent,
menuItems: [
S.menuItem()
.title('Refresh')
.icon(SyncIcon)
.action(refresh.notify)
.serialize(),
],
}),
)
.serialize();
}
export default createBulkActionsTable;