From 3b114f23b32355922a6f16a316404493cde47660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Goulart?= Date: Wed, 6 Dec 2023 14:48:26 +0100 Subject: [PATCH] feat: Add table component to viewer (#920) * feat: Add table component to viewer * fix: Fix default rowCount * feat: Add generic data on editor view * fix: Refactor form field table styles * test: Add tests * fix: Turn data source into FEEL only * chore: Remove unnecessary new line * chore: Remove FEEL check on dataSource * chore: Sort by asc first * chore: Remove unnecessary label check * chore: Use const instead of let * fix: Create EditorTable * chore: Fix formatting * chore: Make label id optional * fix: Add row gap --- .../behavior/TableDataSourceBehavior.js | 25 + .../src/features/modeling/behavior/index.js | 7 +- .../entries/TableDataSourceEntry.js | 2 +- .../editor-form-fields/EditorTable.js | 54 ++ .../components/editor-form-fields/index.js | 4 +- .../form-js-viewer/assets/form-js-base.css | 110 +++- .../src/render/components/Label.js | 13 + .../render/components/form-fields/Table.js | 357 ++++++++++++- .../form-fields/icons/ArrowDown.svg | 1 + .../components/form-fields/icons/ArrowUp.svg | 1 + .../form-fields/icons/CaretLeft.svg | 1 + .../form-fields/icons/CaretRight.svg | 1 + .../components/form-fields/Table.spec.js | 479 ++++++++++++++++++ 13 files changed, 1026 insertions(+), 29 deletions(-) create mode 100644 packages/form-js-editor/src/features/modeling/behavior/TableDataSourceBehavior.js create mode 100644 packages/form-js-editor/src/render/components/editor-form-fields/EditorTable.js create mode 100644 packages/form-js-viewer/src/render/components/form-fields/icons/ArrowDown.svg create mode 100644 packages/form-js-viewer/src/render/components/form-fields/icons/ArrowUp.svg create mode 100644 packages/form-js-viewer/src/render/components/form-fields/icons/CaretLeft.svg create mode 100644 packages/form-js-viewer/src/render/components/form-fields/icons/CaretRight.svg create mode 100644 packages/form-js-viewer/test/spec/render/components/form-fields/Table.spec.js diff --git a/packages/form-js-editor/src/features/modeling/behavior/TableDataSourceBehavior.js b/packages/form-js-editor/src/features/modeling/behavior/TableDataSourceBehavior.js new file mode 100644 index 000000000..2d480658d --- /dev/null +++ b/packages/form-js-editor/src/features/modeling/behavior/TableDataSourceBehavior.js @@ -0,0 +1,25 @@ +import CommandInterceptor from 'diagram-js/lib/command/CommandInterceptor'; + +import { get } from 'min-dash'; + +export class TableDataSourceBehavior extends CommandInterceptor { + constructor(eventBus) { + super(eventBus); + + this.preExecute('formField.add', function(context) { + + const { formField } = context; + + if (get(formField, [ 'type' ]) !== 'table') { + return; + } + + context.formField = { + ...formField, + dataSource: `=${formField.id}` + }; + }, true); + } +} + +TableDataSourceBehavior.$inject = [ 'eventBus' ]; \ No newline at end of file diff --git a/packages/form-js-editor/src/features/modeling/behavior/index.js b/packages/form-js-editor/src/features/modeling/behavior/index.js index 0e51346b1..739974071 100644 --- a/packages/form-js-editor/src/features/modeling/behavior/index.js +++ b/packages/form-js-editor/src/features/modeling/behavior/index.js @@ -4,6 +4,7 @@ import PathBehavior from './PathBehavior'; import ValidateBehavior from './ValidateBehavior'; import ValuesSourceBehavior from './ValuesSourceBehavior'; import { ColumnsSourceBehavior } from './ColumnsSourceBehavior'; +import { TableDataSourceBehavior } from './TableDataSourceBehavior'; export default { __init__: [ @@ -12,12 +13,14 @@ export default { 'pathBehavior', 'validateBehavior', 'valuesSourceBehavior', - 'columnsSourceBehavior' + 'columnsSourceBehavior', + 'tableDataSourceBehavior' ], idBehavior: [ 'type', IdBehavior ], keyBehavior: [ 'type', KeyBehavior ], pathBehavior: [ 'type', PathBehavior ], validateBehavior: [ 'type', ValidateBehavior ], valuesSourceBehavior: [ 'type', ValuesSourceBehavior ], - columnsSourceBehavior: [ 'type', ColumnsSourceBehavior ] + columnsSourceBehavior: [ 'type', ColumnsSourceBehavior ], + tableDataSourceBehavior: [ 'type', TableDataSourceBehavior ] }; diff --git a/packages/form-js-editor/src/features/properties-panel/entries/TableDataSourceEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/TableDataSourceEntry.js index 21f8ec53a..ccaa323a2 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/TableDataSourceEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/TableDataSourceEntry.js @@ -81,7 +81,7 @@ function Source(props) { debounce, description: 'Specify the source from which to populate the table', element: field, - feel: 'optional', + feel: 'required', getValue, id, label: 'Data source', diff --git a/packages/form-js-editor/src/render/components/editor-form-fields/EditorTable.js b/packages/form-js-editor/src/render/components/editor-form-fields/EditorTable.js new file mode 100644 index 000000000..d17378b14 --- /dev/null +++ b/packages/form-js-editor/src/render/components/editor-form-fields/EditorTable.js @@ -0,0 +1,54 @@ +import { Label, Table } from '@bpmn-io/form-js-viewer'; +import { editorFormFieldClasses } from '../Util'; +import classNames from 'classnames'; + +/** + * @param {import('@bpmn-io/form-js-viewer/src/render/components/form-fields/Table').Props} props + * @returns {import("preact").JSX.Element} + */ +export default function EditorTable(props) { + const { columnsExpression, columns, id, label } = props.field; + const shouldUseMockColumns = + (typeof columnsExpression === 'string' && columnsExpression.length > 0) || + (Array.isArray(columns) && columns.length === 0); + const editorColumns = shouldUseMockColumns + ? [ + { key: '1', label: 'Column 1' }, + { key: '2', label: 'Column 2' }, + { key: '3', label: 'Column 3' } + ] + : columns; + const prefixId = `fjs-form-${id}`; + + return ( +
+
+ ); +} + +EditorTable.config = Table.config; diff --git a/packages/form-js-editor/src/render/components/editor-form-fields/index.js b/packages/form-js-editor/src/render/components/editor-form-fields/index.js index 2dbafe714..51a447f75 100644 --- a/packages/form-js-editor/src/render/components/editor-form-fields/index.js +++ b/packages/form-js-editor/src/render/components/editor-form-fields/index.js @@ -1,7 +1,9 @@ import EditorIFrame from './EditorIFrame'; import EditorText from './EditorText'; +import EditorTable from './EditorTable'; export const editorFormFields = [ EditorIFrame, - EditorText + EditorText, + EditorTable ]; \ No newline at end of file diff --git a/packages/form-js-viewer/assets/form-js-base.css b/packages/form-js-viewer/assets/form-js-base.css index 713e5132d..172868063 100644 --- a/packages/form-js-viewer/assets/form-js-base.css +++ b/packages/form-js-viewer/assets/form-js-base.css @@ -13,6 +13,7 @@ --color-grey-225-10-93: hsl(225, 10%, 93%); --color-grey-225-10-95: hsl(225, 10%, 95%); --color-grey-225-10-97: hsl(225, 10%, 97%); + --color-grey-0-0-88: hsl(0, 0%, 88%); --color-blue-219-100-53: hsl(219, 99%, 53%); --color-blue-219-100-53-05: hsla(219, 99%, 53%, 0.5); @@ -52,10 +53,9 @@ --color-background-inverted: var(--cds-background-inverse, var(--color-grey-225-10-90)); --color-background-inverted-hover: var(--cds-background-inverse-hover, var(--color-grey-225-10-93)); --color-background-active: var(--cds-background-active, var(--color-grey-225-10-75)); - --color-layer: var( - --cds-layer, - var(--cds-layer-01, var(--color-white)) - ); + --color-layer: var(--cds-layer, + var(--cds-layer-01, var(--color-white))); + --color-layer-accent: var(--cds-layer-accent, var(--color-grey-0-0-88)); --color-icon-base: var(--cds-icon-primary, var(--color-black)); --color-icon-inverted: var(--cds-icon-inverse, var(--color-black)); @@ -989,6 +989,108 @@ overflow: hidden; } +.fjs-container .fjs-form-field-table { + display: flex; + flex-direction: column; + row-gap: 4px; +} + +.fjs-container .fjs-table-middle-container { + display: flex; + flex-direction: column; + overflow-x: hidden; + border: 1px solid var(--color-borders-group); + border-radius: 3px; +} + +.fjs-container .fjs-table-middle-container.fjs-table-empty { + border: none; + color: var(--color-text-disabled); + padding-left: 16px; +} + +.fjs-container .fjs-table-inner-container { + display: flex; + flex-direction: column; + overflow-x: auto; +} + +.fjs-container .fjs-table { + overflow-y: auto; + border-collapse: collapse; +} + +.fjs-container .fjs-table-head { + background-color: var(--color-layer-accent); +} + +.fjs-container .fjs-table-th { + min-width: 120px; + cursor: pointer; +} + +.fjs-container .fjs-table-th-label { + user-select: none; + display: flex; + align-items: center; + flex-direction: row; + justify-content: space-between; +} + +.fjs-container .fjs-table-th:focus { + outline: var(--outline-definition); + outline-offset: -1px; +} + +.fjs-container .fjs-table-th, +.fjs-container .fjs-table-td { + text-align: left; + height: 32px; + padding: 0 16px; +} + +.fjs-container .fjs-table-body .fjs-table-tr:not(:last-child) { + border-bottom: 1px solid var(--color-borders-group); +} + +.fjs-container .fjs-table-nav { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + border-top: 1px solid var(--color-borders-group); +} + +.fjs-container .fjs-table-nav-button { + border: unset; + background: unset; + width: 32px; + height: 32px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-left: 1px solid var(--color-borders-group); +} + +.fjs-container .fjs-table-nav-button:first-of-type { + margin-left: 16px; +} + +.fjs-container .fjs-table-nav-button:focus { + outline: var(--outline-definition); + outline-offset: -1px; +} + +.fjs-container .fjs-table-nav-button svg { + width: 16px; +} + +.fjs-container .fjs-table-sort-icon-asc, +.fjs-container .fjs-table-sort-icon-desc { + width: 16px; +} + /** * Flatpickr style adjustments */ diff --git a/packages/form-js-viewer/src/render/components/Label.js b/packages/form-js-viewer/src/render/components/Label.js index 2ca27e724..ddc94e5f3 100644 --- a/packages/form-js-viewer/src/render/components/Label.js +++ b/packages/form-js-viewer/src/render/components/Label.js @@ -2,6 +2,19 @@ import classNames from 'classnames'; import { useSingleLineTemplateEvaluation } from '../hooks'; + +/** + * @typedef Props + * @property {string} [id] + * @property {string|undefined} label + * @property {string} [class] + * @property {boolean} [collapseOnEmpty] + * @property {boolean} [required] + * @property {import("preact").VNode} [children] + * + * @param {Props} props + * @returns {import("preact").JSX.Element} + */ export default function Label(props) { const { id, diff --git a/packages/form-js-viewer/src/render/components/form-fields/Table.js b/packages/form-js-viewer/src/render/components/form-fields/Table.js index 901b9e43f..e4067d576 100644 --- a/packages/form-js-viewer/src/render/components/form-fields/Table.js +++ b/packages/form-js-viewer/src/render/components/form-fields/Table.js @@ -1,8 +1,212 @@ -const type = 'table'; +import { isDefined, isNumber, isObject, isString } from 'min-dash'; +import { useExpressionEvaluation } from '../../hooks'; +import { useEffect, useState } from 'preact/hooks'; +import Label from '../Label'; +import { formFieldClasses, prefixId } from '../Util'; +import ArrowDownIcon from './icons/ArrowDown.svg'; +import ArrowUpIcon from './icons/ArrowUp.svg'; +import CaretLeftIcon from './icons/CaretLeft.svg'; +import CaretRightIcon from './icons/CaretRight.svg'; +import classNames from 'classnames'; +const type = 'table'; +/** + * @typedef {('asc'|'desc')} Direction + * + * @typedef Sorting + * @property {string} key + * @property {Direction} direction + * + * @typedef Column + * @property {string} label + * @property {string} key + * + * @typedef Props + * @property {Object} field + * @property {string} field.id + * @property {Array} [field.columns] + * @property {string} [field.columnsExpression] + * @property {string} [field.label] + * @property {number} [field.rowCount] + * @property {string} [field.dataSource] + * + * @param {Props} props + * @returns {import("preact").JSX.Element} + */ export default function Table(props) { - return 'Table'; + const { field } = props; + const { + columns = [], + columnsExpression, + dataSource = '', + rowCount, + id, + label, + } = field; + + /** @type {[(null|Sorting), import("preact/hooks").StateUpdater]} */ + const [ sortBy, setSortBy ] = useState(null); + const evaluatedColumns = useEvaluatedColumns( + columnsExpression || '', + columns, + ); + const columnKeys = evaluatedColumns.map(({ key }) => key); + const evaluatedDataSource = useExpressionEvaluation(dataSource); + const data = Array.isArray(evaluatedDataSource) ? evaluatedDataSource : []; + const sortedData = + sortBy === null + ? data + : sortByColumn(data, sortBy.key, sortBy.direction); + + /** @type {unknown[][]} */ + const chunkedData = isNumber(rowCount) ? chunk(sortedData, rowCount) : [ sortedData ]; + const [ currentPage, setCurrentPage ] = useState(0); + const currentChunk = chunkedData[currentPage] || []; + + + useEffect(() => { + setCurrentPage(0); + }, [ rowCount, sortBy ]); + + + /** @param {string} key */ + function toggleSortBy(key) { + setSortBy((current) => { + if (current === null || current.key !== key) { + return { + key, + direction: 'asc', + }; + } + + if (current.direction === 'desc') { + return null; + } + + return { + key, + direction: 'desc', + }; + }); + } + + return ( +
+
+ ); } Table.config = { @@ -10,27 +214,138 @@ Table.config = { keyed: false, label: 'Table', group: 'presentation', - create: (options = { }) => ({ - ...options, - rowCount: 10, - columns: [ - { - label: 'ID', - key: 'id' - }, - { - label: 'Name', - key: 'name' - }, - { - label: 'Date', - key: 'date' - } - ] - }), + create: (options = {}) => { + const { + id, + columnsExpression, + columns, + rowCount, + ...remainingOptions + } = options; + + if (isDefined(id) && isNumber(rowCount)) { + remainingOptions['rowCount'] = rowCount; + } + + if (isString(columnsExpression)) { + return { + ...remainingOptions, + id, + columnsExpression, + }; + } + + if (Array.isArray(columns) && columns.every(isColumn)) { + return { + ...remainingOptions, + id, + columns, + }; + } + + return { + ...remainingOptions, + rowCount: 10, + columns: [ + { + label: 'ID', + key: 'id', + }, + { + label: 'Name', + key: 'name', + }, + { + label: 'Date', + key: 'date', + }, + ], + }; + }, initialDemoData: [ { id: 1, name: 'John Doe', date: '31.01.2023' }, { id: 2, name: 'Erika Muller', date: '20.02.2023' }, { id: 3, name: 'Dominic Leaf', date: '11.03.2023' } - ] + ], }; + +// helpers ///////////////////////////// + +/** + * @param {string|void} columnsExpression + * @param {Column[]} fallbackColumns + * @returns {Column[]} + */ +function useEvaluatedColumns(columnsExpression, fallbackColumns) { + + /** @type {Column[]|null} */ + const evaluation = useExpressionEvaluation(columnsExpression || ''); + + return Array.isArray(evaluation) && evaluation.every(isColumn) + ? evaluation + : fallbackColumns; +} + +/** + * @param {any} column + * @returns {column is Column} + */ +function isColumn(column) { + return ( + isObject(column) && isString(column['label']) && isString(column['key']) + ); +} + +/** + * @param {Array} array + * @param {number} size + * @returns {Array} + */ +function chunk(array, size) { + return array.reduce((chunks, item, index) => { + if (index % size === 0) { + chunks.push([ item ]); + } else { + chunks[chunks.length - 1].push(item); + } + + return chunks; + }, []); +} + +/** + * @param {unknown[]} array + * @param {string} key + * @param {Direction} direction + * @returns {unknown[]} + */ +function sortByColumn(array, key, direction) { + return [ ...array ].sort((a, b) => { + if (!isObject(a) || !isObject(b)) { + return 0; + } + + if (direction === 'asc') { + return a[key] > b[key] ? 1 : -1; + } + + return a[key] < b[key] ? 1 : -1; + }); +} + +/** + * @param {null|Sorting} sortBy + * @param {string} key + * @param {string} label + */ +function getHeaderAriaLabel(sortBy, key, label) { + if (sortBy === null || sortBy.key !== key) { + return `Click to sort by ${label} descending`; + } + + if (sortBy.direction === 'asc') { + return 'Click to remove sorting'; + } + + return `Click to sort by ${label} ascending`; +} diff --git a/packages/form-js-viewer/src/render/components/form-fields/icons/ArrowDown.svg b/packages/form-js-viewer/src/render/components/form-fields/icons/ArrowDown.svg new file mode 100644 index 000000000..52be26947 --- /dev/null +++ b/packages/form-js-viewer/src/render/components/form-fields/icons/ArrowDown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/form-js-viewer/src/render/components/form-fields/icons/ArrowUp.svg b/packages/form-js-viewer/src/render/components/form-fields/icons/ArrowUp.svg new file mode 100644 index 000000000..e8c19534a --- /dev/null +++ b/packages/form-js-viewer/src/render/components/form-fields/icons/ArrowUp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/form-js-viewer/src/render/components/form-fields/icons/CaretLeft.svg b/packages/form-js-viewer/src/render/components/form-fields/icons/CaretLeft.svg new file mode 100644 index 000000000..ef133897f --- /dev/null +++ b/packages/form-js-viewer/src/render/components/form-fields/icons/CaretLeft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/form-js-viewer/src/render/components/form-fields/icons/CaretRight.svg b/packages/form-js-viewer/src/render/components/form-fields/icons/CaretRight.svg new file mode 100644 index 000000000..3aa449c85 --- /dev/null +++ b/packages/form-js-viewer/src/render/components/form-fields/icons/CaretRight.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/Table.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/Table.spec.js new file mode 100644 index 000000000..73fa462bb --- /dev/null +++ b/packages/form-js-viewer/test/spec/render/components/form-fields/Table.spec.js @@ -0,0 +1,479 @@ +import { fireEvent, render } from '@testing-library/preact/pure'; + +import Table from '../../../../../src/render/components/form-fields/Table'; + +import { + createFormContainer, + expectNoViolations +} from '../../../../TestHelper'; + +import { WithFormContext } from './helper'; + +let container; + + +describe('Table', function() { + + beforeEach(function() { + container = createFormContainer(); + }); + + afterEach(function() { + container.remove(); + }); + + + it('should render', function() { + + // when + const { container } = createTable({ + field: { + ...defaultField, + columns: MOCK_COLUMNS + } + }); + + // then + const formField = container.querySelector('.fjs-form-field'); + + expect(formField).to.exist; + expect(formField.classList.contains('fjs-form-field-table')).to.be.true; + + expect(container.querySelector('table')).to.exist; + }); + + + it('should show an empty message for no static columns', function() { + + // when + const { container } = createTable(); + + // then + const formField = container.querySelector('.fjs-form-field'); + + expect(formField).to.exist; + expect(formField.classList.contains('fjs-form-field-table')).to.be.true; + + expect(container.querySelector('table')).not.to.exist; + expect(container.querySelector('.fjs-table-empty').textContent).to.eql('Nothing to show.'); + }); + + + it('should show an empty message for no dynamic columns', function() { + + const { columns:_, ...field } = defaultField; + + // when + const { container } = createTable({ + initialData: { + foo:[] + }, + field: { + ...field, + columnsExpression: '=foo' + } + }); + + // then + const formField = container.querySelector('.fjs-form-field'); + + expect(formField).to.exist; + expect(formField.classList.contains('fjs-form-field-table')).to.be.true; + + expect(container.querySelector('table')).not.to.exist; + expect(container.querySelector('.fjs-table-empty').textContent).to.eql('Nothing to show.'); + }); + + + it('should show an empty message for no data', function() { + + // when + const { container } = createTable({ + field: { + ...defaultField, + columns: MOCK_COLUMNS + } + }); + + // then + const formField = container.querySelector('.fjs-form-field'); + + expect(formField).to.exist; + expect(formField.classList.contains('fjs-form-field-table')).to.be.true; + + expect(container.querySelector('table')).to.exist; + expect(container.querySelectorAll('.fjs-table-td')).to.have.length(1); + expect(container.querySelector('.fjs-table-td').textContent).to.eql('Nothing to show.'); + }); + + + it('should render table rows', function() { + + // when + const DATA = [ + { + id: 1, + name: 'foo', + date: '2020-01-01' + }, + { + id: 2, + name: 'bar', + date: '2020-01-02' + } + ]; + const { container } = createTable({ + initialData: { + data: DATA + }, + field: { + ...defaultField, + columns: MOCK_COLUMNS, + dataSource: '=data' + }, + isExpression: () => true, + evaluateExpression: () => DATA + }); + + // then + const headers = container.querySelectorAll('.fjs-table-th'); + expect(headers).to.have.length(3); + + const [ idHeader, nameHeader, dateHeader ] = headers; + + expect(idHeader.textContent).to.eql('ID'); + expect(nameHeader.textContent).to.eql('Name'); + expect(dateHeader.textContent).to.eql('Date'); + + const bodyRows = container.querySelectorAll('.fjs-table-body .fjs-table-tr'); + + expect(bodyRows).to.have.length(2); + + const [ firstRow, secondRow ] = bodyRows; + + expect(firstRow.querySelectorAll('.fjs-table-td')).to.have.length(3); + expect(firstRow.querySelectorAll('.fjs-table-td')[0].textContent).to.eql('1'); + expect(firstRow.querySelectorAll('.fjs-table-td')[1].textContent).to.eql('foo'); + expect(firstRow.querySelectorAll('.fjs-table-td')[2].textContent).to.eql('2020-01-01'); + + expect(secondRow.querySelectorAll('.fjs-table-td')).to.have.length(3); + expect(secondRow.querySelectorAll('.fjs-table-td')[0].textContent).to.eql('2'); + expect(secondRow.querySelectorAll('.fjs-table-td')[1].textContent).to.eql('bar'); + expect(secondRow.querySelectorAll('.fjs-table-td')[2].textContent).to.eql('2020-01-02'); + }); + + + it('should have pagination', function() { + + // when + const DATA = [ + { + id: 1, + name: 'foo', + date: '2020-01-01' + }, + { + id: 2, + name: 'bar', + date: '2020-01-02' + } + ]; + const { container } = createTable({ + initialData: { + data: DATA + }, + field: { + ...defaultField, + columns: MOCK_COLUMNS, + dataSource: '=data', + rowCount: 1 + }, + isExpression: () => true, + evaluateExpression: () => DATA + }); + + // then + expect(container.querySelector('.fjs-table-nav-label')).to.exist; + expect(container.querySelector('.fjs-table-nav-label').textContent).to.eql('1 of 2'); + + expect(container.querySelector('.fjs-table-nav-button[aria-label="Previous page"]')).to.exist; + expect(container.querySelector('.fjs-table-nav-button[aria-label="Previous page"]').disabled).to.be.true; + + expect(container.querySelector('.fjs-table-nav-button[aria-label="Next page"]')).to.exist; + expect(container.querySelector('.fjs-table-nav-button[aria-label="Next page"]').disabled).to.be.false; + + const firstPageRow = container.querySelectorAll('.fjs-table-body .fjs-table-tr'); + + expect(firstPageRow).to.have.length(1); + + const [ firstRow ] = firstPageRow; + + expect(firstRow.querySelectorAll('.fjs-table-td')).to.have.length(3); + expect(firstRow.querySelectorAll('.fjs-table-td')[0].textContent).to.eql('1'); + expect(firstRow.querySelectorAll('.fjs-table-td')[1].textContent).to.eql('foo'); + expect(firstRow.querySelectorAll('.fjs-table-td')[2].textContent).to.eql('2020-01-01'); + + fireEvent.click(container.querySelector('.fjs-table-nav-button[aria-label="Next page"]')); + + expect(container.querySelector('.fjs-table-nav-label').textContent).to.eql('2 of 2'); + + expect(container.querySelector('.fjs-table-nav-button[aria-label="Previous page"]')).to.exist; + expect(container.querySelector('.fjs-table-nav-button[aria-label="Previous page"]').disabled).to.be.false; + + expect(container.querySelector('.fjs-table-nav-button[aria-label="Next page"]')).to.exist; + expect(container.querySelector('.fjs-table-nav-button[aria-label="Next page"]').disabled).to.be.true; + + const secondPageRow = container.querySelectorAll('.fjs-table-body .fjs-table-tr'); + + expect(secondPageRow).to.have.length(1); + + const [ secondRow ] = secondPageRow; + + expect(secondRow.querySelectorAll('.fjs-table-td')).to.have.length(3); + expect(secondRow.querySelectorAll('.fjs-table-td')[0].textContent).to.eql('2'); + expect(secondRow.querySelectorAll('.fjs-table-td')[1].textContent).to.eql('bar'); + expect(secondRow.querySelectorAll('.fjs-table-td')[2].textContent).to.eql('2020-01-02'); + }); + + + it('should sort rows', function() { + + // when + const DATA = [ + { + id: 1, + name: 'foo', + date: '2020-01-01' + }, + { + id: 2, + name: 'bar', + date: '2020-01-02' + } + ]; + const { container } = createTable({ + initialData: { + data: DATA + }, + field: { + ...defaultField, + columns: MOCK_COLUMNS, + dataSource: '=data', + rowCount: 1 + }, + isExpression: () => true, + evaluateExpression: () => DATA + }); + + // then + const unsortedRows = container.querySelectorAll('.fjs-table-body .fjs-table-tr'); + + expect(unsortedRows).to.have.length(1); + + const [ firstRow ] = unsortedRows; + + expect(firstRow.querySelectorAll('.fjs-table-td')[0].textContent).to.eql('1'); + + const headers = container.querySelectorAll('.fjs-table-th'); + expect(headers).to.have.length(3); + + fireEvent.click(headers[0]); + + expect(container.querySelector('.fjs-table-sort-icon-asc')).to.exist; + + const rowsSortedAsc = container.querySelectorAll('.fjs-table-body .fjs-table-tr'); + + expect(rowsSortedAsc).to.have.length(1); + + const [ secondRow ] = rowsSortedAsc; + + expect(secondRow.querySelectorAll('.fjs-table-td')).to.have.length(3); + expect(secondRow.querySelectorAll('.fjs-table-td')[0].textContent).to.eql('1'); + + fireEvent.click(headers[0]); + + expect(container.querySelector('.fjs-table-sort-icon-asc')).not.to.exist; + expect(container.querySelector('.fjs-table-sort-icon-desc')).to.exist; + + const rowsSortedDesc = container.querySelectorAll('.fjs-table-body .fjs-table-tr'); + + expect(rowsSortedDesc).to.have.length(1); + + const [ thirdRow ] = rowsSortedDesc; + + expect(thirdRow.querySelectorAll('.fjs-table-td')).to.have.length(3); + expect(thirdRow.querySelectorAll('.fjs-table-td')[0].textContent).to.eql('2'); + + fireEvent.click(headers[0]); + + expect(container.querySelector('.fjs-table-sort-icon-asc')).not.to.exist; + expect(container.querySelector('.fjs-table-sort-icon-desc')).not.to.exist; + + const finalUnsortedRows = container.querySelectorAll('.fjs-table-body .fjs-table-tr'); + + expect(finalUnsortedRows).to.have.length(1); + + const [ fourthRow ] = finalUnsortedRows; + + expect(fourthRow.querySelectorAll('.fjs-table-td')).to.have.length(3); + expect(fourthRow.querySelectorAll('.fjs-table-td')[0].textContent).to.eql('1'); + }); + + + it('should render table label', function() { + + // when + const label = 'foo'; + + const { container } = createTable({ + field: { + ...defaultField, + label + } + }); + + // then + const formField = container.querySelector('.fjs-form-field'); + + const tableLabel = formField.querySelector('.fjs-form-field-label'); + + expect(tableLabel).to.exist; + expect(tableLabel.textContent).to.eql(label); + }); + + + it('should render iframe title (expression)', function() { + + // when + const label = 'foo'; + + const { container } = createTable({ + initialData: { + label + }, + field: { + ...defaultField, + label: '=label' + } + }); + + // then + const formField = container.querySelector('.fjs-form-field'); + + const tableLabel = formField.querySelector('.fjs-form-field-label'); + + expect(tableLabel).to.exist; + expect(tableLabel.textContent).to.eql(label); + }); + + + it('should render table label (template)', function() { + + // when + const label = 'foo'; + + const { container } = createTable({ + initialData: { + label + }, + field: { + ...defaultField, + label: '{{ label }}' + } + }); + + // then + const formField = container.querySelector('.fjs-form-field'); + + const tableLabel = formField.querySelector('.fjs-form-field-label'); + + expect(tableLabel).to.exist; + expect(tableLabel.textContent).to.eql(label); + }); + + + it('#create', function() { + + // assume + const { config } = Table; + expect(config.type).to.eql('table'); + expect(config.label).to.eql('Table'); + expect(config.group).to.eql('presentation'); + expect(config.keyed).to.be.false; + + // when + const field = config.create(); + + // then + expect(field).to.exist; + + // but when + const customField = config.create({ + custom: true + }); + + // then + expect(customField).to.contain({ + custom: true + }); + }); + + + describe('a11y', function() { + + it('should have no violations', async function() { + + // given + this.timeout(5000); + + const { container } = createTable(); + + // then + await expectNoViolations(container); + }); + + }); + +}); + +// helpers ////////// + +const MOCK_COLUMNS = [ + { + label: 'ID', + key: 'id' + }, + { + label: 'Name', + key: 'name' + }, + { + label: 'Date', + key: 'date' + } +]; + +const defaultField = { + label: 'A table', + columns: [], + dataSource: '=foo', + type: 'table' +}; + +function createTable(options = {}) { + const { + field = defaultField, + isExpression = () => false + } = options; + + return render(WithFormContext( + , + { + ...options, + isExpression + } + ), { + container: options.container || container.querySelector('.fjs-form') + }); +} \ No newline at end of file