diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3f2743227..40c697d97 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,6 +7,7 @@ export * from './common/types'; export * from './common/viewport'; export * from './common/time'; export * from './common/combineProviders'; +export * from './common/number'; export * from './data-module/TimeSeriesDataModule'; export * from './mockWidgetProperties'; diff --git a/packages/table/global.d.ts b/packages/table/global.d.ts index 95a14658c..f2216e7d7 100644 --- a/packages/table/global.d.ts +++ b/packages/table/global.d.ts @@ -1,2 +1,7 @@ // eslint-disable-next-line import/no-extraneous-dependencies import 'jest-extended'; + +export declare global { + // eslint-disable-next-line no-var,vars-on-top + var IS_REACT_ACT_ENVIRONMENT: boolean; +} diff --git a/packages/table/jest.config.js b/packages/table/jest.config.js index b2b57d11e..a31bc8173 100644 --- a/packages/table/jest.config.js +++ b/packages/table/jest.config.js @@ -1,8 +1,9 @@ /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ module.exports = { preset: 'ts-jest', - testEnvironment: 'node', + testEnvironment: 'jsdom', setupFilesAfterEnv: ['jest-extended/all'], + coverageDirectory: 'coverage', collectCoverageFrom: ['src/**/*.{ts,tsx}'], testPathIgnorePatterns: ['/dist'], coverageReporters: ['text-summary', 'cobertura', 'html', 'json', 'json-summary'], diff --git a/packages/table/package.json b/packages/table/package.json index 8b89a09a3..84860a379 100644 --- a/packages/table/package.json +++ b/packages/table/package.json @@ -44,6 +44,7 @@ "eslint-config-airbnb-typescript": "^12.3.1", "jest": "^28.1.0", "jest-cli": "^28.1.0", + "jest-environment-jsdom": "^28.1.1", "jest-extended": "^2.0.0", "rollup-plugin-import-css": "^3.0.3", "sass": "^1.30.0", diff --git a/packages/table/src/index.ts b/packages/table/src/index.ts index 52f8fe894..dc746ee27 100644 --- a/packages/table/src/index.ts +++ b/packages/table/src/index.ts @@ -1,2 +1,2 @@ -// export * from './table'; WIP +export * from './table'; export * from './utils'; diff --git a/packages/table/src/table/index.tsx b/packages/table/src/table/index.tsx new file mode 100644 index 000000000..3dc2f0824 --- /dev/null +++ b/packages/table/src/table/index.tsx @@ -0,0 +1,18 @@ +import React, { FunctionComponent } from 'react'; +import { PropertyFilter, Table as AWSUITable } from '@awsui/components-react'; +import { useCollection } from '@awsui/collection-hooks'; +import { TableProps } from '../utils'; +import { defaultI18nStrings, getDefaultColumnDefinitions } from '../utils/tableHelpers'; + +export const Table: FunctionComponent = (props) => { + const { items, useCollectionOption = { sorting: {} }, columnDefinitions } = props; + const { collectionProps, propertyFilterProps } = useCollection(items, useCollectionOption); + return ( + } + /> + ); +}; diff --git a/packages/table/src/utils/iconUtilis.spec.ts b/packages/table/src/utils/iconUtilis.spec.ts new file mode 100644 index 000000000..6f98d9319 --- /dev/null +++ b/packages/table/src/utils/iconUtilis.spec.ts @@ -0,0 +1,78 @@ +import { STATUS_ICONS, StatusIcon } from '@synchro-charts/core'; +import { getIcons } from './iconUtils'; + +describe('sets default pixel size', () => { + it.each(STATUS_ICONS)('renders %p at correct default size', (iconName) => { + const icon = getIcons(iconName)!; + expect(icon?.props.width).toBe('16px'); + expect(icon?.props.height).toBe('16px'); + }); +}); + +describe('sets customized color', () => { + it.each(STATUS_ICONS)('renders %p with customized color or stroke', (iconName) => { + const icon = getIcons(iconName, '#fffff')!; + expect(icon?.props.fill || icon?.props.stroke).toBe('#fffff'); + }); +}); + +describe('sets icon size', () => { + it.each(STATUS_ICONS)('renders %p at the correct size', (iconName) => { + const size = 100; + const icon = getIcons(iconName, undefined, size)!; + expect(icon.props.width).toBe(`${size}px`); + expect(icon.props.height).toBe(`${size}px`); + }); +}); + +it('returns normal icon from StatusIcon.ACKNOWLEDGED provided', () => { + const iconName = StatusIcon.ACKNOWLEDGED; + const icon = getIcons(iconName)!; + expect(icon.props.stroke).toEqual('#3184c2'); +}); + +it('returns normal icon from StatusIcon.ACTIVE provided', () => { + const iconName = StatusIcon.ACTIVE; + const icon = getIcons(iconName)!; + expect(icon.props.fill).toEqual('#d13212'); +}); + +it('returns normal icon from StatusIcon.ACKNOWLEDGED provided', () => { + const iconName = StatusIcon.ACKNOWLEDGED; + const icon = getIcons(iconName)!; + expect(icon.props.stroke).toEqual('#3184c2'); +}); + +it('returns normal icon from StatusIcon.DISABLED provided', () => { + const iconName = StatusIcon.DISABLED; + const icon = getIcons(iconName)!; + expect(icon.props.stroke).toEqual('#687078'); +}); + +it('returns normal icon from StatusIcon.LATCHED provided', () => { + const iconName = StatusIcon.LATCHED; + const icon = getIcons(iconName)!; + expect(icon.props.fill).toEqual('#f89256'); +}); + +it('returns normal icon from StatusIcon.SNOOZED provided', () => { + const iconName = StatusIcon.SNOOZED; + const icon = getIcons(iconName)!; + expect(icon.props.stroke).toEqual('#879596'); +}); + +it('returns normal icon from StatusIcon.SNOOZED provided with color', () => { + const iconName = StatusIcon.SNOOZED; + const icon = getIcons(iconName, 'white')!; + expect(icon.props.stroke).toEqual('white'); +}); + +it('returns error icon from StatusIcon.ERROR', () => { + const iconName = StatusIcon.ERROR; + const icon = getIcons(iconName); + expect(icon).not.toBeNull(); +}); + +it('returned undefined when invalid icon requested', () => { + expect(getIcons('fake-icon' as StatusIcon)).toBeUndefined(); +}); diff --git a/packages/table/src/utils/iconUtils.tsx b/packages/table/src/utils/iconUtils.tsx new file mode 100644 index 000000000..baae8b505 --- /dev/null +++ b/packages/table/src/utils/iconUtils.tsx @@ -0,0 +1,113 @@ +import React, { ReactElement, SVGAttributes } from 'react'; +import { StatusIcon } from '@synchro-charts/core'; + +const DEFAULT_SIZE_PX = 16; + +export const icons = { + normal(color?: string, size: number = DEFAULT_SIZE_PX) { + return ( + + + + + ); + }, + active(color?: string, size: number = DEFAULT_SIZE_PX) { + return ( + + + + + + + + + + + + + + ); + }, + acknowledged(color?: string, size: number = DEFAULT_SIZE_PX) { + return ( + + + + + ); + }, + disabled(color?: string, size: number = DEFAULT_SIZE_PX) { + return ( + + + + + + + ); + }, + latched(color?: string, size: number = DEFAULT_SIZE_PX) { + return ( + + + + + ); + }, + snoozed(color?: string, size: number = DEFAULT_SIZE_PX) { + return ( + + + + + + + ); + }, + error(color?: string, size: number = DEFAULT_SIZE_PX) { + return ( + + + + + ); + }, +}; + +export const getIcons: ( + name: StatusIcon, + color?: string, + size?: number +) => ReactElement> | undefined = (name: StatusIcon, color?: string, size?: number) => { + if (icons[name]) { + return icons[name](color, size); + } + /* eslint-disable-next-line no-console */ + console.warn(`Invalid status icon requested: ${name}`); + return undefined; +}; diff --git a/packages/table/src/utils/index.ts b/packages/table/src/utils/index.ts index e3f571144..95efe0352 100644 --- a/packages/table/src/utils/index.ts +++ b/packages/table/src/utils/index.ts @@ -1,2 +1,2 @@ export { createTableItems } from './createTableItems'; -export { CellItem, Item, ItemRef, TableItem } from './types'; +export { CellItem, Item, ItemRef, TableItem, TableProps, ColumnDefinition, CellItemProps } from './types'; diff --git a/packages/table/src/utils/spinner.spec.tsx b/packages/table/src/utils/spinner.spec.tsx new file mode 100644 index 000000000..a3f0f1f4b --- /dev/null +++ b/packages/table/src/utils/spinner.spec.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { LoadingSpinner } from './spinner'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +describe('size', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + container.remove(); + }); + }); + + it('does not specify height and width when no size provided', async () => { + act(() => { + root.render(); + }); + + const svgElement = container.getElementsByTagName('svg').item(0)!; + + expect(svgElement.style.height).toBe(''); + expect(svgElement.style.width).toBe(''); + }); + + it('does specify height and width when size provided', async () => { + const SIZE = 34; + act(() => { + root.render(); + }); + const svgElement = container.getElementsByTagName('svg').item(0)!; + expect(svgElement.style.height).toBe(`${SIZE}px`); + expect(svgElement.style.width).toBe(`${SIZE}px`); + }); +}); + +describe('dark mode', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + container.remove(); + }); + }); + + it('does not have the dark class present when dark is false', async () => { + act(() => { + root.render(); + }); + const svgElement = container.getElementsByTagName('svg').item(0)!; + expect(svgElement.classList).not.toContain('dark'); + }); + + it('does not have the dark class present when dark is false', async () => { + act(() => { + root.render(); + }); + const svgElement = container.getElementsByTagName('svg').item(0)!; + expect(svgElement.classList).toContain('dark'); + }); +}); diff --git a/packages/table/src/utils/spinner.tsx b/packages/table/src/utils/spinner.tsx new file mode 100644 index 000000000..71c50f0bf --- /dev/null +++ b/packages/table/src/utils/spinner.tsx @@ -0,0 +1,61 @@ +// Copied from Synchro-charts "ScLoadingSpinner" +import React from 'react'; + +export const LoadingSpinner: React.FunctionComponent<{ size?: number; dark?: boolean } | Record> = ( + props +) => { + const { size, dark = false } = props; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/table/src/utils/tableHelpers.spec.ts b/packages/table/src/utils/tableHelpers.spec.ts new file mode 100644 index 000000000..efebc97eb --- /dev/null +++ b/packages/table/src/utils/tableHelpers.spec.ts @@ -0,0 +1,102 @@ +import { ReactElement } from 'react'; +import { act } from 'react-dom/test-utils'; +import { createRoot, Root } from 'react-dom/client'; +import { getDefaultColumnDefinitions } from './tableHelpers'; +import { ColumnDefinition, TableItem } from './types'; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('getDefaultColumnDefinitions', () => { + it('returns valid Polaris table columnDefinitions', () => { + const userColumnDefinitions: ColumnDefinition[] = [ + { + key: 'key1', + header: 'Header', + }, + ]; + + const columnDefs = getDefaultColumnDefinitions(userColumnDefinitions); + expect(columnDefs[0]).toMatchObject({ cell: expect.toBeFunction(), header: 'Header' }); + }); +}); + +describe('default cell function', () => { + let root: Root; + const container = document.createElement('div'); + document.body.appendChild(container); + + beforeEach(() => { + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + }); + + const userColumnDefinitions: ColumnDefinition[] = [ + { + key: 'data', + header: 'Data', + }, + ]; + const columnDef = getDefaultColumnDefinitions(userColumnDefinitions)[0]; + + it("returns item's value", () => { + const item: TableItem = { + data: { + value: 10, + error: undefined, + isLoading: undefined, + valueOf: jest.fn(), + }, + }; + const cell = columnDef.cell(item) as ReactElement; + act(() => { + root.render(cell); + }); + + expect(container.textContent).toContain('10'); + }); + + it('returns error message when in error state', () => { + const item: TableItem = { + data: { + value: 10, + error: { + msg: 'Some Error', + }, + isLoading: undefined, + valueOf: jest.fn(), + }, + }; + const cell = columnDef.cell(item) as ReactElement; + act(() => { + root.render(cell); + }); + + expect(container.textContent).toContain('Some Error'); + }); + + it('returns loading svg when in error state', () => { + const item: TableItem = { + data: { + value: 10, + error: undefined, + isLoading: true, + valueOf: jest.fn(), + }, + }; + + const cell = columnDef.cell(item) as ReactElement; + act(() => { + root.render(cell); + }); + + const svgElement = container.getElementsByTagName('svg'); + expect(svgElement.item(0)?.getAttribute('data-testid')).toEqual('loading'); + }); +}); diff --git a/packages/table/src/utils/tableHelpers.tsx b/packages/table/src/utils/tableHelpers.tsx new file mode 100644 index 000000000..faa6f3563 --- /dev/null +++ b/packages/table/src/utils/tableHelpers.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { PropertyFilterProps, TableProps as AWSUITableProps } from '@awsui/components-react'; +import { StatusIcon } from '@synchro-charts/core'; +import { round } from '@iot-app-kit/core'; +import { ColumnDefinition, TableItem } from './types'; +import { getIcons } from './iconUtils'; +import { LoadingSpinner } from './spinner'; + +export const getDefaultColumnDefinitions: ( + useColumnDefinitions: ColumnDefinition[] +) => AWSUITableProps.ColumnDefinition[] = (useColumnDefinitions) => { + return useColumnDefinitions.map((colDef) => ({ + cell: (item: TableItem) => { + const { error, isLoading, value, threshold } = item[colDef.key]; + const { color = 'unset', icon } = threshold || {}; + if (error) { + return ( +
+ {getIcons(StatusIcon.ERROR)} {error.msg} +
+ ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (colDef.formatter && value) { + return ( + + {icon ? getIcons(icon) : null} {colDef.formatter(value)} + + ); + } + + if (typeof value === 'number') { + return ( + + {icon ? getIcons(icon) : null} {round(value)} + + ); + } + return ( + + {icon ? getIcons(icon) : null} {value} + + ); + }, + ...colDef, + id: colDef.id || colDef.key, + })); +}; + +export const defaultI18nStrings: PropertyFilterProps.I18nStrings = { + filteringAriaLabel: 'your choice', + dismissAriaLabel: 'Dismiss', + filteringPlaceholder: 'Search', + groupValuesText: 'Values', + groupPropertiesText: 'Properties', + operatorsText: 'Operators', + operationAndText: 'and', + operationOrText: 'or', + operatorLessText: 'Less than', + operatorLessOrEqualText: 'Less than or equal', + operatorGreaterText: 'Greater than', + operatorGreaterOrEqualText: 'Greater than or equal', + operatorContainsText: 'Contains', + operatorDoesNotContainText: 'Does not contain', + operatorEqualsText: 'Equals', + operatorDoesNotEqualText: 'Does not equal', + editTokenHeader: 'Edit filter', + propertyText: 'Property', + operatorText: 'Operator', + valueText: 'Value', + cancelActionText: 'Cancel', + applyActionText: 'Apply', + allPropertiesLabel: 'All properties', + tokenLimitShowMore: 'Show more', + tokenLimitShowFewer: 'Show fewer', + clearFiltersText: 'Clear filters', + removeTokenButtonAriaLabel: () => 'Remove token', + enteredTextLabel: (text) => `Use: "${text}"`, +}; diff --git a/packages/table/src/utils/types.ts b/packages/table/src/utils/types.ts index 404ef6a71..c8825ac3f 100644 --- a/packages/table/src/utils/types.ts +++ b/packages/table/src/utils/types.ts @@ -1,5 +1,7 @@ -import { Primitive, Threshold } from '@synchro-charts/core'; +import { Annotations, Primitive, Threshold } from '@synchro-charts/core'; import { ErrorDetails } from '@iot-app-kit/core'; +import { TableProps as AWSUITableProps } from '@awsui/components-react'; +import { UseCollectionOptions } from '@awsui/collection-hooks/dist/cjs/interfaces'; export type ItemRef = { $cellRef: { @@ -28,4 +30,15 @@ export type CellItem = { toString: () => string; }; -export type TableItem = { [k in string]: CellItem }; +export type TableItem = { [k: string]: CellItem }; + +export interface ColumnDefinition extends Omit, 'cell'> { + formatter?: (data: Primitive) => React.ReactNode; + key: string; +} + +export interface TableProps extends Omit, 'columnDefinitions'> { + useCollectionOption?: UseCollectionOptions; + annotations?: Annotations; + columnDefinitions: ColumnDefinition[]; +} diff --git a/packages/table/tsconfig.json b/packages/table/tsconfig.json index 4a423c786..240c033f1 100644 --- a/packages/table/tsconfig.json +++ b/packages/table/tsconfig.json @@ -2,7 +2,6 @@ "compilerOptions": { "allowSyntheticDefaultImports": true, "allowUnreachableCode": false, - "experimentalDecorators": true, "lib": [ "dom", "es2019"