Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions packages/react-vanilla-components/__tests__/components/Table.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <table> 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 <thead> and <tbody>', () => {
const { renderResponse } = helper(tableField);
expect(renderResponse.container.querySelector('thead')).not.toBeNull();
expect(renderResponse.container.querySelector('tbody')).not.toBeNull();
});

test('header items render as <th> in <thead>', () => {
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 <tr> in <tbody>', () => {
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 <td>', () => {
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 <tr> 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 <tr> 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 = <Table {...(state as any)} />;
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();
});
});
152 changes: 152 additions & 0 deletions packages/react-vanilla-components/src/components/Table/Table.tsx
Original file line number Diff line number Diff line change
@@ -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<SortState>({ 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 (
<div
className={`cmp-adaptiveform-table cmp-container ${appliedCssClassNames || ''}`}
id={id}
data-cmp-is="adaptiveFormTable"
data-cmp-visible={visible}
data-cmp-enabled={enabled}
data-cmp-sorting-enabled={enableSorting ? 'true' : 'false'}
>
{label?.visible !== false && (
<div className="cmp-adaptiveform-table__title cmp-container__label">
{label?.value}
</div>
)}
<div className="cmp-adaptiveform-table__help-container">
{props.tooltip && (
<div
className="cmp-adaptiveform-table__questionmark"
id={`${id}__shortdescription`}
>
{props.tooltip}
</div>
)}
</div>
{props.description && (
<div
className="cmp-adaptiveform-table__longdescription"
id={`${id}__longdescription`}
>
{props.description}
</div>
)}
<table
className="cmp-adaptiveform-table__widget"
aria-label={label?.value || ''}
style={tableStyle}
>
{colWidths.length > 0 && (
<colgroup>
{colWidths.map((w, i) => (
<col key={i} style={{ width: w }} />
))}
</colgroup>
)}
<thead className="cmp-adaptiveform-table__head">
{headerItems.map((item: any) => (
<TableHeader
key={item.id}
{...item}
enableSorting={enableSorting}
sortColIndex={sortState.colIndex}
sortDirection={sortState.direction}
onSort={handleSort}
/>
))}
</thead>
<tbody className="cmp-adaptiveform-table__body">
{rowItems.map((item: any) => (
<TableRow key={item.id} {...item} />
))}
</tbody>
</table>
</div>
);
};

export default withRuleEnginePanel(Table);
Loading
Loading