From a22e3b03e4dc47d7038706d8e9cbca73f1e69e6b Mon Sep 17 00:00:00 2001 From: Armaan Gupta Date: Wed, 12 Aug 2026 12:42:56 +0530 Subject: [PATCH 1/2] feat(Table) : added Table Component --- .../__tests__/components/Table.test.tsx | 204 ++++++++++++++++++ .../src/components/Table/Table.tsx | 69 ++++++ .../src/components/Table/TableHeader.tsx | 73 +++++++ .../src/components/Table/TableRow.tsx | 76 +++++++ .../src/components/Table/index.ts | 3 + .../react-vanilla-components/src/index.ts | 8 +- .../src/utils/mappings.ts | 8 +- 7 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 packages/react-vanilla-components/__tests__/components/Table.test.tsx create mode 100644 packages/react-vanilla-components/src/components/Table/Table.tsx create mode 100644 packages/react-vanilla-components/src/components/Table/TableHeader.tsx create mode 100644 packages/react-vanilla-components/src/components/Table/TableRow.tsx create mode 100644 packages/react-vanilla-components/src/components/Table/index.ts 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..ed03480 --- /dev/null +++ b/packages/react-vanilla-components/src/components/Table/Table.tsx @@ -0,0 +1,69 @@ +// ******************************************************************************* +// * 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 { withRuleEnginePanel } from '../../utils/withRuleEngine'; +import { PROPS_PANEL } from '../../utils/type'; +import LabelWithDescription from '../common/LabelWithDescription'; +import TableHeader from './TableHeader'; +import TableRow from './TableRow'; + +const Table = (props: PROPS_PANEL) => { + const { items, id, visible, enabled, label, appliedCssClassNames } = props; + const enableSorting = (props as any).enableSorting as boolean; + + const headerItems = items.filter((item: any) => item[':type'] === 'table-header'); + const rowItems = items.filter((item: any) => item[':type'] === 'table-row'); + + return ( +
+ +
+ + {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..d671d3e --- /dev/null +++ b/packages/react-vanilla-components/src/components/Table/TableHeader.tsx @@ -0,0 +1,73 @@ +// ******************************************************************************* +// * 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 TableHeaderProps = { + id: string; + visible?: boolean; + enabled?: boolean; + items?: any[]; + enableSorting?: boolean; +}; + +const TableHeader = (props: TableHeaderProps) => { + const { id, visible, enabled, items = [], enableSorting } = props; + // @ts-ignore + const { mappings } = useContext(FormContext); + + return ( + + {items.map((cell: any) => { + const Comp = getRenderer(cell, mappings); + const content = Comp ? : null; + return ( + + {enableSorting ? ( +
+
{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..e64245e --- /dev/null +++ b/packages/react-vanilla-components/src/components/Table/TableRow.tsx @@ -0,0 +1,76 @@ +// ******************************************************************************* +// * 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 TableRowProps = { + id: string; + visible?: boolean; + enabled?: boolean; + readOnly?: boolean; + repeatable?: boolean; + items?: any[]; +}; + +const TableRow = (props: TableRowProps) => { + const { id, visible, enabled, readOnly, repeatable, items = [] } = props; + // @ts-ignore + const { mappings } = useContext(FormContext); + + return ( + + {items.map((cell: any, i: number) => { + const Comp = getRenderer(cell, mappings); + const isLast = i === items.length - 1; + const tdClass = `cmp-adaptiveform-tablecell${repeatable && isLast ? ' cmp-adaptiveform-tablecell--with-row-controls' : ''}`; + return ( + + {Comp ? : null} + {repeatable && isLast && ( +
+
+ )} + + ); + })} + + ); +}; + +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 From b8e6060650d44f7666a05e0202067e17eb5eee85 Mon Sep 17 00:00:00 2001 From: Armaan Gupta Date: Sat, 15 Aug 2026 23:10:24 +0530 Subject: [PATCH 2/2] handled complex properties like colspan, colwidth and sorting behaviour --- .../src/components/Table/Table.tsx | 103 ++++++++++++++++-- .../src/components/Table/TableHeader.tsx | 28 ++++- .../src/components/Table/TableRow.tsx | 55 +++++++--- 3 files changed, 156 insertions(+), 30 deletions(-) diff --git a/packages/react-vanilla-components/src/components/Table/Table.tsx b/packages/react-vanilla-components/src/components/Table/Table.tsx index ed03480..f10c322 100644 --- a/packages/react-vanilla-components/src/components/Table/Table.tsx +++ b/packages/react-vanilla-components/src/components/Table/Table.tsx @@ -18,18 +18,70 @@ // ****************************************************************************** import React from 'react'; +import { FormContext } from '@aemforms/af-react-renderer'; import { withRuleEnginePanel } from '../../utils/withRuleEngine'; import { PROPS_PANEL } from '../../utils/type'; -import LabelWithDescription from '../common/LabelWithDescription'; 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 rowItems = items.filter((item: any) => item[':type'] === 'table-row'); + 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 (
{ data-cmp-enabled={enabled} data-cmp-sorting-enabled={enableSorting ? 'true' : 'false'} > - + {label?.visible !== false && ( +
+ {label?.value} +
+ )} +
+ {props.tooltip && ( +
+ {props.tooltip} +
+ )} +
+ {props.description && ( +
+ {props.description} +
+ )} + {colWidths.length > 0 && ( + + {colWidths.map((w, i) => ( + + ))} + + )} {headerItems.map((item: any) => ( - + ))} diff --git a/packages/react-vanilla-components/src/components/Table/TableHeader.tsx b/packages/react-vanilla-components/src/components/Table/TableHeader.tsx index d671d3e..fb7755e 100644 --- a/packages/react-vanilla-components/src/components/Table/TableHeader.tsx +++ b/packages/react-vanilla-components/src/components/Table/TableHeader.tsx @@ -20,16 +20,21 @@ 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 } = props; + const { id, visible, enabled, items = [], enableSorting, sortColIndex = -1, sortDirection = null, onSort } = props; // @ts-ignore const { mappings } = useContext(FormContext); @@ -41,23 +46,36 @@ const TableHeader = (props: TableHeaderProps) => { data-cmp-visible={visible} data-cmp-enabled={enabled} > - {items.map((cell: any) => { + {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 ( { {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 ( -
- {enableSorting ? ( + {showSort ? (
-
{content}
+ {content}
) : ( diff --git a/packages/react-vanilla-components/src/components/Table/TableRow.tsx b/packages/react-vanilla-components/src/components/Table/TableRow.tsx index e64245e..17aedef 100644 --- a/packages/react-vanilla-components/src/components/Table/TableRow.tsx +++ b/packages/react-vanilla-components/src/components/Table/TableRow.tsx @@ -18,6 +18,7 @@ // ****************************************************************************** import React, { useContext } from 'react'; +import { AddItem, RemoveItem } from '@aemforms/af-core'; import { FormContext, getRenderer } from '@aemforms/af-react-renderer'; type TableRowProps = { @@ -26,13 +27,23 @@ type TableRowProps = { enabled?: boolean; readOnly?: boolean; repeatable?: boolean; + minItems?: number; + maxItems?: number; items?: any[]; }; const TableRow = (props: TableRowProps) => { - const { id, visible, enabled, readOnly, repeatable, items = [] } = props; + const { id, visible, enabled, readOnly, repeatable, minItems, maxItems, items = [] } = props; // @ts-ignore - const { mappings } = useContext(FormContext); + 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 (
+ {Comp ? : null} {repeatable && isLast && (
-
)}