diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 12e303c7..e370cd8b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -18,7 +18,7 @@ module.exports = { rules: { "react-refresh/only-export-components": "warn", "no-multiple-empty-lines": "error", - quotes: ["error", "double"], + quotes: ["error", "double", { "avoidEscape": true }], "arrow-parens": ["error", "as-needed"], "prefer-arrow-functions/prefer-arrow-functions": [ "warn", diff --git a/src/components/Grid/useColumns.test.ts b/src/components/Grid/useColumns.test.ts index bddc0cb0..ca33b0e8 100644 --- a/src/components/Grid/useColumns.test.ts +++ b/src/components/Grid/useColumns.test.ts @@ -125,7 +125,6 @@ describe("useColumns", () => { result.current.initColumnSize(300); result.current.onColumnResize(1, 0, "auto"); }); - // eslint-disable-next-line quotes expect(mockQuerySelector).toHaveBeenCalledWith('[data-grid-column="1"]'); expect(result.current.columnWidth(1)).toBe(122); }); diff --git a/src/components/Table/Table.stories.tsx b/src/components/Table/Table.stories.tsx index 69b9f27b..c48268f3 100644 --- a/src/components/Table/Table.stories.tsx +++ b/src/components/Table/Table.stories.tsx @@ -135,3 +135,77 @@ export const Sortable: StoryObj = { ); }, }; + +export const ConfigurableColumns: StoryObj = { + args: { + headers: [ + { id: "company", label: "Company", required: true }, + { id: "contact", label: "Contact", selected: true }, + { id: "country", label: "Country", selected: false }, + ], + rows, + enableColumnVisibility: true, + tableId: "demo-table", + onLoadColumnVisibility: undefined, + onSaveColumnVisibility: undefined, + }, + render: props => { + return ( +
+

+ Click the settings icon in the top-right corner to configure visible columns. + The "Company" column is required and cannot be hidden. Uses default + localStorage with key "click-ui-table-column-visibility-demo-table". +

+ + + ); + }, +}; + +export const ConfigurableColumnsCustomStorage: StoryObj = { + args: { + headers: [ + { id: "company", label: "Company", required: true }, + { id: "contact", label: "Contact" }, + { id: "country", label: "Country" }, + ], + rows, + enableColumnVisibility: true, + tableId: "custom-table", + }, + render: props => { + // Example: Custom storage implementation + const handleLoad = (tableId: string): Record => { + try { + const stored = localStorage.getItem(`my-app-columns-${tableId}`); + return stored ? JSON.parse(stored) : {}; + } catch { + return {}; + } + }; + + const handleSave = (tableId: string, visibility: Record) => { + try { + localStorage.setItem(`my-app-columns-${tableId}`, JSON.stringify(visibility)); + } catch { + // Handle error silently + } + }; + + return ( +
+

+ This example uses custom storage with key prefix "my-app-columns-". + You can provide your own onLoadColumnVisibility and onSaveColumnVisibility for + custom storage (API, IndexedDB, sessionStorage, etc.). +

+
+ + ); + }, +}; diff --git a/src/components/Table/Table.test.tsx b/src/components/Table/Table.test.tsx index 1edcfe14..dd3105fe 100644 --- a/src/components/Table/Table.test.tsx +++ b/src/components/Table/Table.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent } from "@testing-library/react"; +import { fireEvent, waitFor } from "@testing-library/react"; import { Table, TableProps } from "./Table"; import { renderCUI } from "@/utils/test-utils"; @@ -98,4 +98,141 @@ describe("Table", () => { fireEvent.click(rowCheckbox); expect(onEdit).toBeCalledTimes(1); }); + + describe("Column Visibility", () => { + const headersWithIds = [ + { id: "company", label: "Company" }, + { id: "contact", label: "Contact" }, + { id: "country", label: "Country" }, + ]; + + it("should render column visibility button and toggle columns", async () => { + const { queryByTestId, container } = renderCUI( +
+ ); + + // Column visibility button should be present + expect(queryByTestId("column-visibility-button")).not.toBeNull(); + + // All columns should be visible by default + let headerCells = container.querySelectorAll("thead th"); + expect(headerCells.length).toBe(4); // 3 columns + 1 settings column + expect(headerCells[0].textContent).toBe("Company"); + expect(headerCells[1].textContent).toBe("Contact"); + expect(headerCells[2].textContent).toBe("Country"); + + // Open popover and hide Contact column + const settingsButton = queryByTestId("column-visibility-button"); + fireEvent.click(settingsButton!); + + // Wait for popover to open and find checkboxes in the document (portal renders outside container) + await waitFor(() => { + const checkboxes = document.querySelectorAll('[role="checkbox"]'); + expect(checkboxes.length).toBeGreaterThan(0); + }); + + const checkboxes = document.querySelectorAll('[role="checkbox"]'); + const contactCheckbox = checkboxes[1]; + fireEvent.click(contactCheckbox); + + // Contact column should now be hidden + headerCells = container.querySelectorAll("thead th"); + const headerTexts = Array.from(headerCells).map(cell => cell.textContent); + expect(headerTexts).toContain("Company"); + expect(headerTexts).not.toContain("Contact"); + expect(headerTexts).toContain("Country"); + }); + + it("should not allow hiding mandatory columns", async () => { + const headersWithMandatory = [ + { id: "company", label: "Company", mandatory: true }, + { id: "contact", label: "Contact" }, + { id: "country", label: "Country" }, + ]; + + const { queryByTestId } = renderCUI( +
+ ); + + const settingsButton = queryByTestId("column-visibility-button"); + fireEvent.click(settingsButton!); + + // Wait for popover to open and find checkboxes in the document (portal renders outside container) + await waitFor(() => { + const checkboxes = document.querySelectorAll('[role="checkbox"]'); + expect(checkboxes.length).toBeGreaterThan(0); + }); + + const checkboxes = document.querySelectorAll('[role="checkbox"]'); + const companyCheckbox = checkboxes[0]; + + // Mandatory column checkbox should be disabled (Radix UI sets data-disabled="" when disabled) + expect(companyCheckbox.hasAttribute("data-disabled")).toBe(true); + }); + + it("should call onLoadColumnVisibility and onSaveColumnVisibility", async () => { + const onLoad = vi.fn(() => ({ + company: true, + contact: false, + country: true, + })); + const onSave = vi.fn(); + + const { queryByTestId, container } = renderCUI( +
+ ); + + // onLoad should be called on mount + expect(onLoad).toHaveBeenCalledWith("test-table-storage"); + + // Contact column should be hidden based on loaded state + const headerCells = container.querySelectorAll("thead th"); + const headerTexts = Array.from(headerCells).map(cell => cell.textContent); + expect(headerTexts).not.toContain("Contact"); + + // Toggle Country column visibility + const settingsButton = queryByTestId("column-visibility-button"); + fireEvent.click(settingsButton!); + + // Wait for popover to open and find checkboxes in the document (portal renders outside container) + await waitFor(() => { + const checkboxes = document.querySelectorAll('[role="checkbox"]'); + expect(checkboxes.length).toBeGreaterThan(0); + }); + + const checkboxes = document.querySelectorAll('[role="checkbox"]'); + const countryCheckbox = checkboxes[2]; + fireEvent.click(countryCheckbox); + + // onSave should be called with updated visibility + expect(onSave).toHaveBeenCalled(); + const lastCall = onSave.mock.calls[onSave.mock.calls.length - 1]; + expect(lastCall[0]).toBe("test-table-storage"); + expect(lastCall[1]).toMatchObject({ + company: true, + contact: false, + country: false, + }); + }); + }); }); diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index f1639957..b2647c67 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,4 +1,13 @@ -import { FC, HTMLAttributes, MouseEvent, ReactNode, forwardRef, useMemo } from "react"; +import { + FC, + HTMLAttributes, + MouseEvent, + ReactNode, + forwardRef, + useMemo, + useState, + useEffect, +} from "react"; import { styled } from "styled-components"; import { CheckedState } from "@radix-ui/react-checkbox"; @@ -11,6 +20,7 @@ import { Icon, IconButton, Text, + Popover, } from "@/components"; type SortDir = "asc" | "desc"; @@ -23,6 +33,9 @@ export interface TableHeaderType extends HTMLAttributes { sortDir?: SortDir; sortPosition?: HorizontalDirection; width?: string; + required?: boolean; + selected?: boolean; + id?: string; } const StyledHeader = styled.th<{ $size: TableSize }>` @@ -107,6 +120,9 @@ interface TheadProps { size: TableSize; rows: TableRowType[]; selectedIds: (number | string)[]; + enableColumnVisibility?: boolean; + visibleColumns: Record; + onVisibilityChange: (columnId: string, visible: boolean) => void; } const Thead = ({ @@ -118,23 +134,33 @@ const Thead = ({ size, rows, selectedIds, + enableColumnVisibility, + visibleColumns, + onVisibilityChange, }: TheadProps) => { const onSort = (header: TableHeaderType, headerIndex: number) => () => { if (typeof onSortProp === "function" && header.isSortable) { onSortProp(header.sortDir === "asc" ? "desc" : "asc", header, headerIndex); } }; + + const visibleHeaders = headers.filter((header, index) => { + const columnId = header.id || `column-${index}`; + return visibleColumns[columnId] !== false; + }); + return ( <> {isSelectable && } - {headers.map((headerProps, index) => ( + {visibleHeaders.map((headerProps, index) => ( ))} {actionsList.length > 0 && } + {enableColumnVisibility && } @@ -150,7 +176,7 @@ const Thead = ({ /> )} - {headers.map(({ width, ...headerProps }, index) => ( + {visibleHeaders.map(({ width, ...headerProps }, index) => ( )} + {enableColumnVisibility && ( + + + + )} @@ -404,6 +442,10 @@ interface CommonTableProps size?: TableSize; showHeader?: boolean; rowHeight?: string; + tableId?: string; + enableColumnVisibility?: boolean; + onLoadColumnVisibility?: (tableId: string) => Record; + onSaveColumnVisibility?: (tableId: string, visibility: Record) => void; } type SelectReturnValue = { @@ -435,6 +477,8 @@ interface TableBodyRowProps extends Omit { actionsList: Array; size: TableSize; rowHeight?: string; + visibleColumns: Record; + enableColumnVisibility?: boolean; } const TableBodyRow = ({ @@ -452,10 +496,24 @@ const TableBodyRow = ({ size, actionsList, rowHeight, + visibleColumns, + enableColumnVisibility, ...rowProps }: TableBodyRowProps) => { const isDeletable = typeof onDelete === "function"; const isEditable = typeof onEdit === "function"; + + const visibleItems = items.filter((_, cellIndex) => { + const header = headers[cellIndex]; + const columnId = header?.id || `column-${cellIndex}`; + return visibleColumns[columnId] !== false; + }); + + const visibleHeaders = headers.filter((header, index) => { + const columnId = header.id || `column-${index}`; + return visibleColumns[columnId] !== false; + }); + return ( )} - {items.map(({ label, ...cellProps }, cellIndex) => ( - - {headers[cellIndex] && {headers[cellIndex].label}} - {label} - - ))} + {visibleItems.map(({ label, ...cellProps }, visibleIndex) => { + const originalIndex = items.findIndex( + item => item === visibleItems[visibleIndex] + ); + return ( + + {visibleHeaders[visibleIndex] && ( + {visibleHeaders[visibleIndex].label} + )} + {label} + + ); + })} {actionsList.length > 0 && ( @@ -512,6 +577,7 @@ const TableBodyRow = ({ )} + {enableColumnVisibility && } ); }; @@ -576,6 +642,10 @@ const Table = forwardRef( size = "sm", showHeader = true, rowHeight, + tableId, + enableColumnVisibility = false, + onLoadColumnVisibility, + onSaveColumnVisibility, ...props }, ref @@ -583,6 +653,42 @@ const Table = forwardRef( const isDeletable = typeof onDelete === "function"; const isEditable = typeof onEdit === "function"; + // Initialize column visibility from storage + const [visibleColumns, setVisibleColumns] = useState>(() => { + if (!enableColumnVisibility || !tableId) return {}; + + const loadFn = onLoadColumnVisibility || defaultLoadColumnVisibility; + const stored = loadFn(tableId); + const initial: Record = {}; + + headers.forEach((header, index) => { + const columnId = header.id || `column-${index}`; + // If required, always visible. Otherwise, if selected show by default, else hidden + if (header.required) { + initial[columnId] = true; + } else { + initial[columnId] = stored[columnId] ?? header.selected; + } + }); + + return initial; + }); + + // Save to storage when visibility changes + useEffect(() => { + if (enableColumnVisibility && tableId) { + const saveFn = onSaveColumnVisibility || defaultSaveColumnVisibility; + saveFn(tableId, visibleColumns); + } + }, [visibleColumns, enableColumnVisibility, tableId, onSaveColumnVisibility]); + + const handleVisibilityChange = (columnId: string, visible: boolean) => { + setVisibleColumns(prev => ({ + ...prev, + [columnId]: visible, + })); + }; + const onRowSelect = (id: number | string) => (checked: boolean): void => { @@ -612,6 +718,13 @@ const Table = forwardRef( actionsList.push("editAction"); } + const visibleHeadersCount = enableColumnVisibility + ? headers.filter((header, index) => { + const columnId = header.id || `column-${index}`; + return visibleColumns[columnId] !== false; + }).length + : headers.length; + return ( {hasRows && showHeader && ( @@ -641,15 +754,19 @@ const Table = forwardRef( size={size} rows={rows} selectedIds={selectedIds} + enableColumnVisibility={enableColumnVisibility} + visibleColumns={visibleColumns} + onVisibilityChange={handleVisibilityChange} /> )} {(loading || !hasRows) && ( ( } size={size} rowHeight={rowHeight} + visibleColumns={visibleColumns} + enableColumnVisibility={enableColumnVisibility} {...rowProps} /> ))} @@ -777,4 +896,110 @@ const StyledTable = styled.table` } `; +// Default storage implementation (used as fallback) +const defaultLoadColumnVisibility = (tableId: string): Record => { + const key = `click-ui-table-column-visibility-${tableId}`; + + try { + const stored = localStorage.getItem(key); + return stored ? JSON.parse(stored) : {}; + } catch { + return {}; + } +}; + +const defaultSaveColumnVisibility = ( + tableId: string, + visibility: Record +) => { + const key = `click-ui-table-column-visibility-${tableId}`; + + try { + localStorage.setItem(key, JSON.stringify(visibility)); + } catch { + // Silently fail if localStorage is not available + } +}; + +// Styled components for column visibility popover +const ColumnVisibilityContainer = styled.div` + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 200px; + max-width: 300px; +`; + +const ColumnVisibilityItem = styled.label<{ $disabled?: boolean }>` + display: flex; + align-items: center; + gap: 0.5rem; + cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")}; + opacity: ${({ $disabled }) => ($disabled ? 0.6 : 1)}; + user-select: none; +`; + +const ColumnVisibilityLabel = styled.span` + ${({ theme }) => ` + font: ${theme.click.table.cell.text.default}; + color: ${theme.click.table.row.color.text.default}; + `} +`; + +// Column Visibility Popover Component +interface ColumnVisibilityPopoverProps { + headers: Array; + visibleColumns: Record; + onVisibilityChange: (columnId: string, visible: boolean) => void; +} + +const ColumnVisibilityPopover: FC = ({ + headers, + visibleColumns, + onVisibilityChange, +}) => { + return ( + + + + + + + {headers.map((header, index) => { + const columnId = header.id || `column-${index}`; + const isRequired = header.required === true; + const isVisible = visibleColumns[columnId] !== false; + + return ( + + {isRequired ? : { + if (!isRequired) { + onVisibilityChange(columnId, checked === true); + } + }} + />} + {header.label} + + ); + })} + + + + ); +}; + export { Table };