diff --git a/packages/react-vanilla-components/__tests__/components/Table.test.tsx b/packages/react-vanilla-components/__tests__/components/Table.test.tsx new file mode 100644 index 0000000..f64944a --- /dev/null +++ b/packages/react-vanilla-components/__tests__/components/Table.test.tsx @@ -0,0 +1,204 @@ +/* + * Copyright 2026 Adobe, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import Table from '../../src/components/Table/Table'; +import { createForm, Provider, renderComponent } from '../utils'; +import '@testing-library/jest-dom/extend-expect'; + +const headerItem = { + id: 'header-test', + fieldType: 'panel', + ':type': 'table-header', + name: 'header', + label: { value: 'Header Row', visible: true }, + visible: true, + enabled: true, + index: 0, + items: [ + { id: 'col1', fieldType: 'plain-text', ':type': 'plain-text', name: 'col1', value: 'Column 1', index: 0 }, + { id: 'col2', fieldType: 'plain-text', ':type': 'plain-text', name: 'col2', value: 'Column 2', index: 1 }, + ], +}; + +const rowItem = { + id: 'row1-test', + fieldType: 'panel', + ':type': 'table-row', + name: 'row1', + label: { value: 'Row 1', visible: true }, + visible: true, + enabled: true, + index: 1, + items: [ + { id: 'cell1', fieldType: 'text-input', ':type': 'text-input', name: 'cell1', type: 'string', label: { value: '' }, index: 0 }, + { id: 'cell2', fieldType: 'text-input', ':type': 'text-input', name: 'cell2', type: 'string', label: { value: '' }, index: 1 }, + ], +}; + +const tableField = { + id: 'table-test', + fieldType: 'panel', + ':type': 'table', + name: 'table1', + label: { value: 'Test Table', visible: true }, + visible: true, + enabled: true, + index: 0, + enableSorting: false, + items: [headerItem, rowItem], +}; + +const fullField = { + id: 'table-52dfd36109', + fieldType: 'panel', + name: 'table1786517710602', + ':type': 'table', + label: { value: 'Table' }, + visible: true, + enabled: true, + index: 0, + enableSorting: true, + items: [ + { + id: 'tableheader-5ae4718dd5', + fieldType: 'panel', + ':type': 'table-header', + name: 'header1786517710631', + label: { value: 'Header Row' }, + visible: true, + enabled: true, + index: 0, + items: [ + { id: 'text-b805a4780f', fieldType: 'plain-text', name: 'column1', value: 'Column 1', index: 0 }, + { id: 'text-9575c7c94f', fieldType: 'plain-text', name: 'column2', value: 'Column 2', index: 1 }, + ], + }, + { + id: 'tablerow-6c377ff30e', + fieldType: 'panel', + ':type': 'table-row', + name: 'row11786517710665', + label: { value: 'Row 1' }, + visible: true, + enabled: true, + index: 1, + items: [ + { id: 'textinput-c849d0b688', fieldType: 'text-input', name: 'cell1', type: 'string', label: { value: '' }, index: 0 }, + { id: 'textinput-4e1d3fc2df', fieldType: 'text-input', name: 'cell2', type: 'string', label: { value: '' }, index: 1 }, + ], + }, + ], +}; + +const helper = renderComponent(Table); + +describe('Table', () => { + test('renders element', () => { + const { renderResponse } = helper(tableField); + expect(renderResponse.container.querySelector('table')).not.toBeNull(); + }); + + test('renders label', () => { + const { renderResponse } = helper(tableField); + expect(renderResponse.queryByText(tableField.label.value)).not.toBeNull(); + }); + + test('renders and ', () => { + const { renderResponse } = helper(tableField); + expect(renderResponse.container.querySelector('thead')).not.toBeNull(); + expect(renderResponse.container.querySelector('tbody')).not.toBeNull(); + }); + + test('header items render as ', () => { + const { renderResponse } = helper(tableField); + const thead = renderResponse.container.querySelector('thead'); + const headers = thead?.querySelectorAll('th'); + expect(headers?.length).toBe(2); + }); + + test('row items render as in ', () => { + const { renderResponse } = helper(tableField); + const tbody = renderResponse.container.querySelector('tbody'); + const rows = tbody?.querySelectorAll('tr'); + expect(rows?.length).toBe(1); + }); + + test('row cells render as has adaptiveFormTableHeader data-cmp-is', () => { + const { renderResponse } = helper(tableField); + const headerRow = renderResponse.container.querySelector('.cmp-adaptiveform-tableheader'); + expect(headerRow?.getAttribute('data-cmp-is')).toBe('adaptiveFormTableHeader'); + }); + + test('body has adaptiveFormTableRow data-cmp-is', () => { + const { renderResponse } = helper(tableField); + const dataRow = renderResponse.container.querySelector('.cmp-adaptiveform-tablerow'); + expect(dataRow?.getAttribute('data-cmp-is')).toBe('adaptiveFormTableRow'); + }); + + test('tooltip and description toggle correctly', async () => { + const f = { + ...tableField, + tooltip: 'Short Description', + description: 'Long Description', + properties: { 'afs:layout': { tooltipVisible: true } }, + }; + const { renderResponse } = await helper(f); + expect(renderResponse.getByText('Short Description')).not.toBeNull(); + const button = renderResponse.container.getElementsByClassName('cmp-adaptiveform-table__questionmark'); + if (button.length > 0) { + (button[0] as HTMLButtonElement).click(); + expect(renderResponse.getByText('Long Description')).not.toBeNull(); + } + }); + + test('full model.json field renders without errors', async () => { + const form = await createForm(fullField); + const state = form.items[0].getState(); + const tableEl =
in
', () => { + const { renderResponse } = helper(tableField); + const tbody = renderResponse.container.querySelector('tbody'); + const cells = tbody?.querySelectorAll('td'); + expect(cells?.length).toBe(2); + }); + + test('table is hidden when visible is false', () => { + const { renderResponse } = helper({ ...tableField, visible: false }); + expect(renderResponse.queryByText(tableField.label.value)).toBeNull(); + }); + + test('sort buttons absent when enableSorting is false', () => { + const { renderResponse } = helper({ ...tableField, enableSorting: false }); + const sortButtons = renderResponse.container.getElementsByClassName('cmp-adaptiveform-table__sort-button'); + expect(sortButtons.length).toBe(0); + }); + + test('sort buttons appear when enableSorting is true', () => { + const { renderResponse } = helper({ ...tableField, enableSorting: true }); + const sortButtons = renderResponse.container.getElementsByClassName('cmp-adaptiveform-table__sort-button'); + expect(sortButtons.length).toBe(2); + }); + + test('cmp-adaptiveform-table BEM block on root div', () => { + const { renderResponse } = helper(tableField); + const root = renderResponse.container.querySelector('.cmp-adaptiveform-table'); + expect(root).not.toBeNull(); + expect(root?.getAttribute('data-cmp-is')).toBe('adaptiveFormTable'); + }); + + test('header
; + const wrapper = Provider(form); + const { container } = render(tableEl, { wrapper }); + expect(container.querySelector('table')).not.toBeNull(); + expect(container.querySelector('thead')).not.toBeNull(); + expect(container.querySelector('tbody')).not.toBeNull(); + }); +}); diff --git a/packages/react-vanilla-components/src/components/Table/Table.tsx b/packages/react-vanilla-components/src/components/Table/Table.tsx new file mode 100644 index 0000000..f10c322 --- /dev/null +++ b/packages/react-vanilla-components/src/components/Table/Table.tsx @@ -0,0 +1,152 @@ +// ******************************************************************************* +// * Copyright 2026 Adobe +// * +// * Licensed under the Apache License, Version 2.0 (the "License"); +// * you may not use this file except in compliance with the License. +// * You may obtain a copy of the License at +// * +// * http://www.apache.org/licenses/LICENSE-2.0 +// * +// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +// * limitations under the License. +// * +// * BEM markup follows AEM core form components guidelines. +// * LINK- https://github.com/adobe/aem-core-forms-components +// ****************************************************************************** + +import React from 'react'; +import { FormContext } from '@aemforms/af-react-renderer'; +import { withRuleEnginePanel } from '../../utils/withRuleEngine'; +import { PROPS_PANEL } from '../../utils/type'; +import TableHeader from './TableHeader'; +import TableRow from './TableRow'; + +type SortDirection = 'asc' | 'desc' | null; + +type SortState = { + colIndex: number; + direction: SortDirection; +}; + +const Table = (props: PROPS_PANEL) => { + const { items, id, visible, enabled, label, appliedCssClassNames } = props; + const enableSorting = (props as any).enableSorting as boolean; + const columnWidth = ((props as any).columnWidth + || (props as any).properties?.['fd:dor']?.columnWidth) as string | undefined; + + // @ts-ignore + const { form } = React.useContext(FormContext); + + const headerItems = items.filter((item: any) => item[':type'] === 'table-header'); + const rawRowItems = items.filter((item: any) => item[':type'] === 'table-row'); + + const [sortState, setSortState] = React.useState({ colIndex: -1, direction: null }); + + const handleSort = React.useCallback((colIndex: number) => { + setSortState(prev => { + if (prev.colIndex !== colIndex) return { colIndex, direction: 'asc' }; + return { colIndex, direction: prev.direction === 'asc' ? 'desc' : 'asc' }; + }); + }, []); + + const getLiveCellValue = React.useCallback((rowId: string, colIndex: number): string => { + const rowEl = form?.getElement(rowId); + if (!rowEl) return ''; + const rowState = rowEl.getState(); + const cell = rowState.items?.[colIndex]; + if (!cell) return ''; + const val = cell.value ?? cell.default ?? ''; + return String(val).toLowerCase(); + }, [form]); + + const rowItems = React.useMemo(() => { + if (!sortState.direction || sortState.colIndex < 0) return rawRowItems; + return [...rawRowItems].sort((a: any, b: any) => { + const av = getLiveCellValue(a.id, sortState.colIndex); + const bv = getLiveCellValue(b.id, sortState.colIndex); + const cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }); + return sortState.direction === 'asc' ? cmp : -cmp; + }); + }, [rawRowItems, sortState, getLiveCellValue]); + + // Parse "1,2,1" into percentage widths: total=4, cols=[25%, 50%, 25%] + const colWidths: string[] = React.useMemo(() => { + if (!columnWidth) return []; + const parts = columnWidth.split(',').map((s: string) => Number(s.trim())).filter(Boolean); + if (!parts.length) return []; + const total = parts.reduce((a: number, b: number) => a + b, 0); + return parts.map((w: number) => `${((w / total) * 100).toFixed(2)}%`); + }, [columnWidth]); + + const tableStyle = columnWidth ? { tableLayout: 'fixed' as const, width: '100%' } : undefined; + + return ( +
+ {label?.visible !== false && ( +
+ {label?.value} +
+ )} +
+ {props.tooltip && ( +
+ {props.tooltip} +
+ )} +
+ {props.description && ( +
+ {props.description} +
+ )} +
+ {colWidths.length > 0 && ( + + {colWidths.map((w, i) => ( + + ))} + + )} + + {headerItems.map((item: any) => ( + + ))} + + + {rowItems.map((item: any) => ( + + ))} + +
+ + ); +}; + +export default withRuleEnginePanel(Table); diff --git a/packages/react-vanilla-components/src/components/Table/TableHeader.tsx b/packages/react-vanilla-components/src/components/Table/TableHeader.tsx new file mode 100644 index 0000000..fb7755e --- /dev/null +++ b/packages/react-vanilla-components/src/components/Table/TableHeader.tsx @@ -0,0 +1,91 @@ +// ******************************************************************************* +// * Copyright 2026 Adobe +// * +// * Licensed under the Apache License, Version 2.0 (the "License"); +// * you may not use this file except in compliance with the License. +// * You may obtain a copy of the License at +// * +// * http://www.apache.org/licenses/LICENSE-2.0 +// * +// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +// * limitations under the License. +// * +// * BEM markup follows AEM core form components guidelines. +// * LINK- https://github.com/adobe/aem-core-forms-components +// ****************************************************************************** + +import React, { useContext } from 'react'; +import { FormContext, getRenderer } from '@aemforms/af-react-renderer'; + +type SortDirection = 'asc' | 'desc' | null; + +type TableHeaderProps = { + id: string; + visible?: boolean; + enabled?: boolean; + items?: any[]; + enableSorting?: boolean; + sortColIndex?: number; + sortDirection?: SortDirection; + onSort?: (colIndex: number) => void; +}; + +const TableHeader = (props: TableHeaderProps) => { + const { id, visible, enabled, items = [], enableSorting, sortColIndex = -1, sortDirection = null, onSort } = props; + // @ts-ignore + const { mappings } = useContext(FormContext); + + return ( + + {items.map((cell: any, index: number) => { + const Comp = getRenderer(cell, mappings); + const content = Comp ? : null; + const colspan = cell.properties?.colspan || cell.colspan; + const disableSorting = cell.properties?.disableSorting; + const showSort = enableSorting && !disableSorting; + const isActive = sortColIndex === index; + const sortDir: SortDirection = isActive ? sortDirection : null; + + return ( + + {showSort ? ( +
+ {content} +
+ ) : ( + content + )} + + ); + })} + + ); +}; + +export default TableHeader; diff --git a/packages/react-vanilla-components/src/components/Table/TableRow.tsx b/packages/react-vanilla-components/src/components/Table/TableRow.tsx new file mode 100644 index 0000000..17aedef --- /dev/null +++ b/packages/react-vanilla-components/src/components/Table/TableRow.tsx @@ -0,0 +1,101 @@ +// ******************************************************************************* +// * Copyright 2026 Adobe +// * +// * Licensed under the Apache License, Version 2.0 (the "License"); +// * you may not use this file except in compliance with the License. +// * You may obtain a copy of the License at +// * +// * http://www.apache.org/licenses/LICENSE-2.0 +// * +// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +// * limitations under the License. +// * +// * BEM markup follows AEM core form components guidelines. +// * LINK- https://github.com/adobe/aem-core-forms-components +// ****************************************************************************** + +import React, { useContext } from 'react'; +import { AddItem, RemoveItem } from '@aemforms/af-core'; +import { FormContext, getRenderer } from '@aemforms/af-react-renderer'; + +type TableRowProps = { + id: string; + visible?: boolean; + enabled?: boolean; + readOnly?: boolean; + repeatable?: boolean; + minItems?: number; + maxItems?: number; + items?: any[]; +}; + +const TableRow = (props: TableRowProps) => { + const { id, visible, enabled, readOnly, repeatable, minItems, maxItems, items = [] } = props; + // @ts-ignore + const { mappings, form } = useContext(FormContext); + + const element = repeatable ? form?.getElement(id) : null; + const instanceCount = element ? element.getState().items?.length ?? 1 : 1; + const showAdd = repeatable && (maxItems === undefined || instanceCount < maxItems); + const showRemove = repeatable && (minItems === undefined || instanceCount > minItems); + + const handleAdd = () => element?.dispatch(new AddItem()); + const handleRemove = () => element?.dispatch(new RemoveItem()); + + return ( + + {items.map((cell: any, i: number) => { + const Comp = getRenderer(cell, mappings); + const isLast = i === items.length - 1; + const colspan = cell.properties?.colspan || cell.colspan; + const tdClass = `cmp-adaptiveform-tablecell${repeatable && isLast ? ' cmp-adaptiveform-tablecell--with-row-controls' : ''}`; + + return ( + + {Comp ? : null} + {repeatable && isLast && ( +
+ {showAdd && ( +
+ )} + + ); + })} + + ); +}; + +export default TableRow; diff --git a/packages/react-vanilla-components/src/components/Table/index.ts b/packages/react-vanilla-components/src/components/Table/index.ts new file mode 100644 index 0000000..b959092 --- /dev/null +++ b/packages/react-vanilla-components/src/components/Table/index.ts @@ -0,0 +1,3 @@ +export { default as Table } from './Table'; +export { default as TableHeader } from './TableHeader'; +export { default as TableRow } from './TableRow'; diff --git a/packages/react-vanilla-components/src/index.ts b/packages/react-vanilla-components/src/index.ts index dbbaa2a..0b8d22e 100644 --- a/packages/react-vanilla-components/src/index.ts +++ b/packages/react-vanilla-components/src/index.ts @@ -31,6 +31,9 @@ import mappings from './utils/mappings'; import ReCaptcha from './components/ReCaptcha'; import Image from './components/Image'; import Scribble from './components/Scribble'; +import Table from './components/Table/Table'; +import TableHeader from './components/Table/TableHeader'; +import TableRow from './components/Table/TableRow'; export * from './utils/withRuleEngine'; export * from './utils/type'; @@ -60,5 +63,8 @@ export { ReCaptcha, Image, Scribble, - Form + Form, + Table, + TableHeader, + TableRow }; diff --git a/packages/react-vanilla-components/src/utils/mappings.ts b/packages/react-vanilla-components/src/utils/mappings.ts index 90b887a..d08b8c1 100644 --- a/packages/react-vanilla-components/src/utils/mappings.ts +++ b/packages/react-vanilla-components/src/utils/mappings.ts @@ -39,6 +39,9 @@ import ReCaptcha from '../components/ReCaptcha'; import HCaptcha from '../components/HCaptcha'; import Image from '../components/Image'; import Scribble from '../components/Scribble'; +import Table from '../components/Table/Table'; +import TableHeader from '../components/Table/TableHeader'; +import TableRow from '../components/Table/TableRow'; const mappings = { 'text-input': TextField, @@ -67,7 +70,10 @@ const mappings = { captcha: ReCaptcha, image: Image, signature: Scribble, - form: Form + form: Form, + table: Table, + 'table-header': TableHeader, + 'table-row': TableRow }; export default mappings; \ No newline at end of file