diff --git a/packages/sanity/src/structure/panes/documentList/ColumnsControl.tsx b/packages/sanity/src/structure/panes/documentList/ColumnsControl.tsx new file mode 100644 index 00000000000..929819df08c --- /dev/null +++ b/packages/sanity/src/structure/panes/documentList/ColumnsControl.tsx @@ -0,0 +1,81 @@ +/* eslint-disable @sanity/i18n/no-attribute-string-literals */ +/* eslint-disable i18next/no-literal-string */ +import {Box, Button, Card, Checkbox, Flex, Menu, MenuButton, Stack, Text} from '@sanity/ui' +import {type Column, type Table} from '@tanstack/react-table' +import {useCallback} from 'react' +import {type SanityDocument} from 'sanity' + +import {VISIBLE_COLUMN_LIMIT} from './useDocumentSheetColumns' + +type ColumnsControlProps = { + table: Table +} + +export function ColumnsControl({table}: ColumnsControlProps) { + const isVisibleLimitReached = + table.getVisibleLeafColumns().filter((col) => col.getCanHide()).length >= VISIBLE_COLUMN_LIMIT + + const setInitialColumns = useCallback(() => { + table.resetColumnVisibility() + }, [table]) + + const handleColumnOnChange = (column: Column) => () => { + column.toggleVisibility() + } + + const getColumnVisibilityDisabled = (column: Column) => { + const isColumnVisible = column.getIsVisible() + const isSingleColumnVisible = + table.getVisibleLeafColumns().filter((col) => col.getCanHide()).length === 1 + + return (isVisibleLimitReached && !isColumnVisible) || (isSingleColumnVisible && isColumnVisible) + } + + return ( + } + id="columns-control" + menu={ + + + } + placement="bottom" + popover={{portal: true}} + /> + ) +} diff --git a/packages/sanity/src/structure/panes/documentList/DocumentSheetListPane.tsx b/packages/sanity/src/structure/panes/documentList/DocumentSheetListPane.tsx index e6f50d96a15..6592034dd3b 100644 --- a/packages/sanity/src/structure/panes/documentList/DocumentSheetListPane.tsx +++ b/packages/sanity/src/structure/panes/documentList/DocumentSheetListPane.tsx @@ -1,4 +1,4 @@ -import {type SanityDocument, type SchemaType} from '@sanity/types' +import {isDocumentSchemaType, type ObjectSchemaType, type SanityDocument} from '@sanity/types' import {Box, Flex, Text} from '@sanity/ui' import { flexRender, @@ -13,6 +13,7 @@ import {SearchProvider, useSchema, useSearchState} from 'sanity' import {styled} from 'styled-components' import {type BaseStructureToolPaneProps} from '../types' +import {ColumnsControl} from './ColumnsControl' import {DocumentSheetListFilter} from './DocumentSheetListFilter' import {DocumentSheetListPaginator} from './DocumentSheetListPaginator' import {useDocumentSheetColumns} from './useDocumentSheetColumns' @@ -46,6 +47,7 @@ const Table = styled.table` tr { border-bottom: 1px solid lightgray; display: flex; + padding: 0; } tr:last-child { border-bottom: none; @@ -58,17 +60,17 @@ const Table = styled.table` } td { - padding: 0 3px; + padding: 0; } ` function DocumentSheetListPaneInner({ - schemaType, -}: DocumentSheetListPaneProps & {schemaType: SchemaType}) { + documentSchemaType, +}: DocumentSheetListPaneProps & {documentSchemaType: ObjectSchemaType}) { const {dispatch, state} = useSearchState() - const columns = useDocumentSheetColumns(schemaType) + const {columns, initialColumnsVisibility} = useDocumentSheetColumns(documentSchemaType) const {data} = useDocumentSheetList({ - typeName: schemaType.name, + typeName: documentSchemaType.name, }) const totalRows = state.result.hits.length @@ -82,17 +84,18 @@ function DocumentSheetListPaneInner({ autoResetPageIndex: false, initialState: { pagination: {pageSize: 25}, + columnVisibility: initialColumnsVisibility, }, }) const {rows} = table.getRowModel() useEffect(() => { - dispatch({type: 'TERMS_TYPE_ADD', schemaType: schemaType}) + dispatch({type: 'TERMS_TYPE_ADD', schemaType: documentSchemaType}) return () => { - dispatch({type: 'TERMS_TYPE_REMOVE', schemaType: schemaType}) + dispatch({type: 'TERMS_TYPE_REMOVE', schemaType: documentSchemaType}) } - }, [schemaType, dispatch]) + }, [documentSchemaType, dispatch]) const renderRow = useCallback((row: Row) => { return ( @@ -130,6 +133,7 @@ function DocumentSheetListPaneInner({ + {table.getHeaderGroups().map((headerGroup) => ( @@ -164,12 +168,12 @@ export function DocumentSheetListPane(props: DocumentSheetListPaneProps) { const typeName = props.pane.schemaTypeName const schemaType = schema.get(typeName) - if (!schemaType) { - throw new Error(`Schema type "${typeName}" not found`) + if (!schemaType || !isDocumentSchemaType(schemaType)) { + throw new Error(`Schema type "${typeName}" not found or not a document schema`) } return ( - + ) } diff --git a/packages/sanity/src/structure/panes/documentList/SheetListCell.tsx b/packages/sanity/src/structure/panes/documentList/SheetListCell.tsx new file mode 100644 index 00000000000..8229c646fba --- /dev/null +++ b/packages/sanity/src/structure/panes/documentList/SheetListCell.tsx @@ -0,0 +1,52 @@ +/* eslint-disable i18next/no-literal-string */ +import {type ObjectFieldType} from '@sanity/types' +import {Select, TextInput} from '@sanity/ui' +import {type CellContext} from '@tanstack/react-table' +import {type FormEvent, useCallback, useEffect, useState} from 'react' +import {type SanityDocument} from 'sanity' + +interface SheetListCellProps extends CellContext { + fieldType: ObjectFieldType +} + +export function SheetListCell(props: SheetListCellProps) { + const {getValue, column, row, fieldType} = props + const initialValue = getValue() || '' + // We need to keep and update the state of the cell normally + const [value, setValue] = useState(initialValue) + + const handleOnChange = useCallback((e: FormEvent) => { + setValue(e.currentTarget.value) + }, []) + + useEffect(() => { + setValue(initialValue || '') + }, [initialValue]) + + if (fieldType.name === 'boolean') { + return ( + + ) + } + + return ( + + ) +} diff --git a/packages/sanity/src/structure/panes/documentList/__tests__/ColumnsControl.test.tsx b/packages/sanity/src/structure/panes/documentList/__tests__/ColumnsControl.test.tsx new file mode 100644 index 00000000000..ac77d8dc946 --- /dev/null +++ b/packages/sanity/src/structure/panes/documentList/__tests__/ColumnsControl.test.tsx @@ -0,0 +1,99 @@ +import {afterEach} from 'node:test' + +import {beforeEach, describe, expect, it, jest} from '@jest/globals' +import {studioTheme, ThemeProvider} from '@sanity/ui' +import {useReactTable} from '@tanstack/react-table' +import {fireEvent, render, screen} from '@testing-library/react' +import {type SanityDocument} from 'sanity' + +import {ColumnsControl} from '../ColumnsControl' + +const TableHarness = ({columns}) => { + const initialVisibilityState = { + 'First Column': true, + 'Second Column': true, + 'Third Column': true, + 'Nested First Column': true, + 'Nested Second Column': true, + 'Fifth Column': true, + 'Sixth Column': false, + } + const table = useReactTable({ + columns, + data: [], + getCoreRowModel: () => { + throw new Error('getCoreRowModel not implemented.') + }, + initialState: { + columnVisibility: initialVisibilityState, + }, + }) + + return +} + +describe('ColumnsControl', () => { + beforeEach(() => { + render( + + + , + ) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should set default column visibilities', () => { + fireEvent.click(screen.getByText('Columns')) + expect(screen.getByRole('checkbox', {name: 'First Column'})).toBeChecked() + expect(screen.getByRole('checkbox', {name: 'Nested First Column'})).toBeChecked() + expect(screen.getByRole('checkbox', {name: 'Sixth Column'})).not.toBeChecked() + }) + + it('should not allow unhideable columns to be hidden', () => { + fireEvent.click(screen.getByText('Columns')) + expect(screen.queryByRole('checkbox', {name: 'Third Column'})).toBeNull() + }) + + it('should toggle column visibility', () => { + fireEvent.click(screen.getByText('Columns')) + fireEvent.click(screen.getByRole('checkbox', {name: 'First Column'})) + expect(screen.getByRole('checkbox', {name: 'First Column'})).not.toBeChecked() + }) + + it('should not allow more than 5 columns to be visible', () => { + fireEvent.click(screen.getByText('Columns')) + + expect(screen.getByRole('checkbox', {name: 'Sixth Column'})).toBeDisabled() + screen.getByText('You may only have 5 columns visible') + }) + + it('should not allow the last visible column to be hidden', () => { + fireEvent.click(screen.getByText('Columns')) + + fireEvent.click(screen.getByRole('checkbox', {name: 'First Column'})) + fireEvent.click(screen.getByRole('checkbox', {name: 'Second Column'})) + fireEvent.click(screen.getByRole('checkbox', {name: 'Nested Second Column'})) + fireEvent.click(screen.getByRole('checkbox', {name: 'Nested First Column'})) + + expect(screen.getByRole('checkbox', {name: 'Fifth Column'})).toBeDisabled() + }) +}) diff --git a/packages/sanity/src/structure/panes/documentList/__tests__/useDocumentSheetColumns.test.ts b/packages/sanity/src/structure/panes/documentList/__tests__/useDocumentSheetColumns.test.ts new file mode 100644 index 00000000000..9cb3b913859 --- /dev/null +++ b/packages/sanity/src/structure/panes/documentList/__tests__/useDocumentSheetColumns.test.ts @@ -0,0 +1,52 @@ +import {describe, expect, it, jest} from '@jest/globals' +import {renderHook} from '@testing-library/react' + +import {useDocumentSheetColumns} from '../useDocumentSheetColumns' + +jest.mock('sanity', () => ({ + ...(jest.requireActual('sanity') || {}), + useDocumentPreviewStore: jest.fn().mockReturnValue({}), +})) + +describe('useDocumentSheetColumns', () => { + it('returns initial column visibilities', () => { + const mockSchemaType = { + name: 'author', + title: 'Author', + type: 'document', + fields: [ + {name: 'name', type: {name: 'string'}}, + {name: 'nickname', type: {name: 'string'}}, + {name: 'email', type: {name: 'string'}}, + {name: 'age', type: {name: 'number'}}, + { + name: 'address', + type: { + name: 'object', + jsonType: 'object', + fields: [ + {name: 'street', type: {name: 'string'}}, + {name: 'country', type: {name: 'string'}}, + ], + }, + }, + {name: 'phone number', type: {name: 'number'}}, + {name: 'has pet', type: {name: 'boolean'}}, + ], + } + + const {result} = renderHook(() => useDocumentSheetColumns(mockSchemaType)) + expect(result.current.initialColumnsVisibility).toEqual({ + 'Preview': true, + 'selected': true, + 'name': true, + 'nickname': true, + 'email': true, + 'age': true, + 'address.street': true, + 'address.country': false, + 'phone number': false, + 'has pet': false, + }) + }) +}) diff --git a/packages/sanity/src/structure/panes/documentList/useDocumentSheetColumns.tsx b/packages/sanity/src/structure/panes/documentList/useDocumentSheetColumns.tsx index 6f97843782d..d181cce59cf 100644 --- a/packages/sanity/src/structure/panes/documentList/useDocumentSheetColumns.tsx +++ b/packages/sanity/src/structure/panes/documentList/useDocumentSheetColumns.tsx @@ -1,7 +1,12 @@ -// This is a WIP file, to render a very basic table view. -import {Flex, Text, TextInput} from '@sanity/ui' -import {createColumnHelper} from '@tanstack/react-table' -import {type FormEvent, useCallback, useEffect, useMemo, useState} from 'react' +import {isObjectSchemaType, type ObjectSchemaType} from '@sanity/types' +import {Checkbox, Flex, Text} from '@sanity/ui' +import { + type AccessorKeyColumnDef, + createColumnHelper, + type GroupColumnDef, + type VisibilityState, +} from '@tanstack/react-table' +import {useMemo} from 'react' import {useMemoObservable} from 'react-rx' import { type DocumentPreviewStore, @@ -13,6 +18,9 @@ import { } from 'sanity' import {type PaneItemPreviewState} from '../../components/paneItem/types' +import {SheetListCell} from './SheetListCell' + +export const VISIBLE_COLUMN_LIMIT = 5 const PreviewCell = (props: { documentPreviewStore: DocumentPreviewStore @@ -44,83 +52,126 @@ const PreviewCell = (props: { ) } -const TableTextInput = (props: any) => { - const {index, id} = props - const initialValue = props.getValue() - // We need to keep and update the state of the cell normally - const [value, setValue] = useState(initialValue || '') +const columnHelper = createColumnHelper() +const SUPPORTED_FIELDS = ['string', 'number', 'boolean'] - // When the input is blurred, we'll call our table meta's updateData function - const onBlur = () => { - props.table.options.meta?.updateData(index, id, value) - } +type Columns = ( + | AccessorKeyColumnDef + | GroupColumnDef +)[] - const handleChange = useCallback( - (e: FormEvent) => setValue(e.currentTarget.value), - [], - ) +const getColsFromSchemaType = (schemaType: ObjectSchemaType, parentalField?: string): Columns => { + return schemaType.fields.reduce((tableColumns: Columns, field) => { + const {type, name} = field + if (SUPPORTED_FIELDS.includes(type.name)) { + const nextCol = columnHelper.accessor( + parentalField ? `${parentalField}.${field.name}` : field.name, + { + header: field.type.title, + enableHiding: true, + cell: (info) => , + }, + ) + + return [...tableColumns, nextCol] + } - useEffect(() => { - setValue(initialValue) - }, [initialValue]) + // if first layer nested object + if (type.name === 'object' && isObjectSchemaType(type) && !parentalField) { + return [ + ...tableColumns, + columnHelper.group({header: name, columns: getColsFromSchemaType(type, field.name)}), + ] + } - return + return tableColumns + }, []) } -const columnHelper = createColumnHelper() -const SUPPORTED_FIELDS = ['string', 'number'] +// Type guard function to check if a column is of type GroupColumnDef +function isAccessorKeyColumnDef( + column: Columns[number], +): column is AccessorKeyColumnDef { + return 'accessorKey' in column +} +function isGroupColumnDef( + column: AccessorKeyColumnDef | GroupColumnDef, +): column is GroupColumnDef { + return 'columns' in column +} -export function useDocumentSheetColumns(schemaType?: SchemaType) { +const flatColumns = (cols: Columns): AccessorKeyColumnDef[] => { + return cols.flatMap((col) => { + if (isAccessorKeyColumnDef(col)) { + return col + } + if (isGroupColumnDef(col)) { + return col.columns ? flatColumns(col.columns) : [] + } + return [] + }) +} + +export function useDocumentSheetColumns(documentSchemaType?: ObjectSchemaType) { const documentPreviewStore = useDocumentPreviewStore() - const columns = useMemo(() => { - if (!schemaType) { + const columns: Columns = useMemo(() => { + if (!documentSchemaType) { return [] } - const cols = [ - { - header: 'Preview', - //@ts-expect-error - wip. + return [ + columnHelper.accessor('selected', { + enableHiding: false, + header: (info) => ( + + ), + cell: (info) => ( + info.row.toggleSelected()} + /> + ), + }), + columnHelper.accessor('Preview', { + enableHiding: false, cell: (info) => { return ( ) }, - }, - // columnHelper.accessor('_id', { - // header: 'Id', - // cell: (info) => { - // return {info.getValue()} - // }, - // }), + }), + ...getColsFromSchemaType(documentSchemaType), ] - //@ts-expect-error - wip. - for (const field of schemaType.fields) { - if (!SUPPORTED_FIELDS.includes(field.type.name)) { - continue - } - - cols.push( - columnHelper.accessor(field.name, { - header: field.type.title, - //@ts-expect-error dynamic field name access, types not generated correctly. - cell: (info) => { - const renderValue = info.getValue() - // return - if (typeof renderValue === 'string' || typeof renderValue === 'number') { - return {renderValue} - } - return {JSON.stringify(info.getValue())} - }, - }), - ) - } - return cols - }, [documentPreviewStore, schemaType]) + }, [documentPreviewStore, documentSchemaType]) + + const [initialColumnsVisibility]: [VisibilityState, number] = useMemo( + () => + flatColumns(columns).reduce<[VisibilityState, number]>( + ([accCols, countAllowedVisible], column) => { + // this column is always visible + if (!column.enableHiding) { + return [{...accCols, [column.accessorKey]: true}, countAllowedVisible] + } + + // have already reached column visibility limit, hide column by default + if (countAllowedVisible === VISIBLE_COLUMN_LIMIT) { + return [{...accCols, [column.accessorKey]: false}, countAllowedVisible] + } + + return [{...accCols, [column.accessorKey]: true}, countAllowedVisible + 1] + }, + [{}, 0], + ), + [columns], + ) - return columns + return {columns, initialColumnsVisibility} }