diff --git a/change/@ni-nimble-components-fdbf357d-901d-4004-8a37-7ccea6c59bea.json b/change/@ni-nimble-components-fdbf357d-901d-4004-8a37-7ccea6c59bea.json new file mode 100644 index 0000000000..54ab20ada6 --- /dev/null +++ b/change/@ni-nimble-components-fdbf357d-901d-4004-8a37-7ccea6c59bea.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Initial version of date column", + "packageName": "@ni/nimble-components", + "email": "7282195+m-akinc@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/nimble-components/src/table-column/anchor/tests/table-column-anchor.spec.ts b/packages/nimble-components/src/table-column/anchor/tests/table-column-anchor.spec.ts index 997d0d4da7..9e48bbe984 100644 --- a/packages/nimble-components/src/table-column/anchor/tests/table-column-anchor.spec.ts +++ b/packages/nimble-components/src/table-column/anchor/tests/table-column-anchor.spec.ts @@ -70,7 +70,11 @@ describe('TableColumnAnchor', () => { const noValueData = [ { description: 'field not present', data: [{ unused: 'foo' }] }, { description: 'value is null', data: [{ label: null }] }, - { description: 'value is undefined', data: [{ label: undefined }] } + { description: 'value is undefined', data: [{ label: undefined }] }, + { + description: 'value is not a string', + data: [{ label: 10 as unknown as string }] + } ]; for (const testData of noValueData) { // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -192,6 +196,16 @@ describe('TableColumnAnchor', () => { }); describe('with href', () => { + it('displays label when href is not string', async () => { + await element.setData([ + { label: 'foo', link: 10 as unknown as string } + ]); + await connect(); + await waitForUpdatesAsync(); + + expect(pageObject.getRenderedCellContent(0, 0)).toBe('foo'); + }); + it('changing labelFieldName updates display', async () => { await element.setData([ { label: 'foo', otherLabel: 'bar', link: 'url' } @@ -254,6 +268,14 @@ describe('TableColumnAnchor', () => { } describe('with no label', () => { + it('displays empty string when href is not string', async () => { + await element.setData([{ link: 10 as unknown as string }]); + await connect(); + await waitForUpdatesAsync(); + + expect(pageObject.getRenderedCellContent(0, 0)).toBe(''); + }); + it('displays url', async () => { await element.setData([{ link: 'foo' }]); await connect(); diff --git a/packages/nimble-components/src/table-column/date-text/cell-view/index.ts b/packages/nimble-components/src/table-column/date-text/cell-view/index.ts new file mode 100644 index 0000000000..27db891b08 --- /dev/null +++ b/packages/nimble-components/src/table-column/date-text/cell-view/index.ts @@ -0,0 +1,51 @@ +import { DesignSystem } from '@microsoft/fast-foundation'; +import { template } from '../../text-base/cell-view/template'; +import type { + TableColumnDateTextCellRecord, + TableColumnDateTextColumnConfig +} from '..'; +import { styles } from '../../text-base/cell-view/styles'; +import { TableColumnTextCellViewBase } from '../../text-base/cell-view'; + +declare global { + interface HTMLElementTagNameMap { + 'nimble-table-column-date-text-cell-view': TableColumnDateTextCellView; + } +} + +/** + * A cell view for displaying date/time fields as text + */ +export class TableColumnDateTextCellView extends TableColumnTextCellViewBase< +TableColumnDateTextCellRecord, +TableColumnDateTextColumnConfig +> { + private static readonly formatter = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'medium' + }); + + private cellRecordChanged(): void { + if (typeof this.cellRecord.value === 'number') { + try { + this.text = TableColumnDateTextCellView.formatter.format( + this.cellRecord.value + ); + } catch (e) { + this.text = ''; + } + } else { + this.text = ''; + } + } +} + +const dateTextCellView = TableColumnDateTextCellView.compose({ + baseName: 'table-column-date-text-cell-view', + template, + styles +}); +DesignSystem.getOrCreate().withPrefix('nimble').register(dateTextCellView()); +export const tableColumnDateTextCellViewTag = DesignSystem.tagFor( + TableColumnDateTextCellView +); diff --git a/packages/nimble-components/src/table-column/date-text/group-header-view/index.ts b/packages/nimble-components/src/table-column/date-text/group-header-view/index.ts new file mode 100644 index 0000000000..2e8e21db4b --- /dev/null +++ b/packages/nimble-components/src/table-column/date-text/group-header-view/index.ts @@ -0,0 +1,50 @@ +import { DesignSystem } from '@microsoft/fast-foundation'; +import type { TableNumberFieldValue } from '../../../table/types'; +import { TableColumnTextGroupHeaderViewBase } from '../../text-base/group-header-view'; +import { template } from '../../text-base/group-header-view/template'; +import { styles } from '../../text-base/group-header-view/styles'; +import type { TableColumnDateTextColumnConfig } from '..'; + +declare global { + interface HTMLElementTagNameMap { + 'nimble-table-column-date-text-group-header': TableColumnDateTextGroupHeaderView; + } +} +/** + * The group header view for displaying date/time fields as text. + */ +export class TableColumnDateTextGroupHeaderView extends TableColumnTextGroupHeaderViewBase< +TableNumberFieldValue, +TableColumnDateTextColumnConfig +> { + private static readonly formatter = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'medium' + }); + + private groupHeaderValueChanged(): void { + if (typeof this.groupHeaderValue === 'number') { + try { + this.text = TableColumnDateTextGroupHeaderView.formatter.format( + this.groupHeaderValue + ); + } catch (e) { + this.text = ''; + } + } else { + this.text = ''; + } + } +} + +const tableColumnDateTextGroupHeaderView = TableColumnDateTextGroupHeaderView.compose({ + baseName: 'table-column-date-text-group-header', + template, + styles +}); +DesignSystem.getOrCreate() + .withPrefix('nimble') + .register(tableColumnDateTextGroupHeaderView()); +export const tableColumnDateTextGroupHeaderTag = DesignSystem.tagFor( + TableColumnDateTextGroupHeaderView +); diff --git a/packages/nimble-components/src/table-column/date-text/index.ts b/packages/nimble-components/src/table-column/date-text/index.ts new file mode 100644 index 0000000000..eb40a356ff --- /dev/null +++ b/packages/nimble-components/src/table-column/date-text/index.ts @@ -0,0 +1,45 @@ +import { DesignSystem } from '@microsoft/fast-foundation'; +import { styles } from '../base/styles'; +import { template } from '../base/template'; +import type { TableNumberField } from '../../table/types'; +import { TableColumnTextBase } from '../text-base'; +import { TableColumnSortOperation } from '../base/types'; +import { tableColumnDateTextGroupHeaderTag } from './group-header-view'; +import { tableColumnDateTextCellViewTag } from './cell-view'; +import type { ColumnInternalsOptions } from '../base/models/column-internals'; + +export type TableColumnDateTextCellRecord = TableNumberField<'value'>; +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface TableColumnDateTextColumnConfig {} + +declare global { + interface HTMLElementTagNameMap { + 'nimble-table-column-date-text': TableColumnDateText; + } +} + +/** + * The table column for displaying dates/times as text. + */ +export class TableColumnDateText extends TableColumnTextBase { + protected override getColumnInternalsOptions(): ColumnInternalsOptions { + return { + cellRecordFieldNames: ['value'], + cellViewTag: tableColumnDateTextCellViewTag, + groupHeaderViewTag: tableColumnDateTextGroupHeaderTag, + delegatedEvents: [], + sortOperation: TableColumnSortOperation.basic + }; + } +} + +const nimbleTableColumnDateText = TableColumnDateText.compose({ + baseName: 'table-column-date-text', + template, + styles +}); + +DesignSystem.getOrCreate() + .withPrefix('nimble') + .register(nimbleTableColumnDateText()); +export const tableColumnDateTextTag = DesignSystem.tagFor(TableColumnDateText); diff --git a/packages/nimble-components/src/table-column/date-text/testing/table-column-date-text.pageobject.ts b/packages/nimble-components/src/table-column/date-text/testing/table-column-date-text.pageobject.ts new file mode 100644 index 0000000000..88f4cf0958 --- /dev/null +++ b/packages/nimble-components/src/table-column/date-text/testing/table-column-date-text.pageobject.ts @@ -0,0 +1,47 @@ +import type { TablePageObject } from '../../../table/testing/table.pageobject'; +import type { TableRecord } from '../../../table/types'; +import { TableColumnDateTextCellView } from '../cell-view'; + +/** + * Page object for date text table column. + */ +export class TableColumnDateTextPageObject { + // On Chrome, in a formatted date, the space before AM/PM is a narrow non-breaking space. + // For testing consistency across browsers, replace it with a regular space. + private readonly narrowNonBreakingSpace = '\u202f'; + + public constructor(private readonly tablePageObject: TablePageObject) {} + + public getRenderedCellContent( + rowIndex: number, + columnIndex: number + ): string { + this.verifyCellType(rowIndex, columnIndex); + return this.tablePageObject + .getRenderedCellContent(rowIndex, columnIndex) + .replace(this.narrowNonBreakingSpace, ' '); + } + + public getRenderedGroupHeaderContent(groupRowIndex: number): string { + return this.tablePageObject + .getRenderedGroupHeaderContent(groupRowIndex) + .replace(this.narrowNonBreakingSpace, ' '); + } + + public getCellTitle(rowIndex: number, columnIndex: number): string { + this.verifyCellType(rowIndex, columnIndex); + return this.tablePageObject + .getCellTitle(rowIndex, columnIndex) + .replace(this.narrowNonBreakingSpace, ' '); + } + + private verifyCellType(rowIndex: number, columnIndex: number): void { + const cell = this.tablePageObject.getRenderedCellView( + rowIndex, + columnIndex + ); + if (!(cell instanceof TableColumnDateTextCellView)) { + throw new Error('Cell is not in a date text column'); + } + } +} diff --git a/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text-matrix.stories.ts b/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text-matrix.stories.ts new file mode 100644 index 0000000000..df75472760 --- /dev/null +++ b/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text-matrix.stories.ts @@ -0,0 +1,64 @@ +import type { StoryFn, Meta } from '@storybook/html'; +import { html, ViewTemplate } from '@microsoft/fast-element'; +import { createMatrixThemeStory } from '../../../utilities/tests/storybook'; +import { + createMatrix, + sharedMatrixParameters +} from '../../../utilities/tests/matrix'; +import { tableColumnDateTextTag } from '..'; +import { iconUserTag } from '../../../icons/user'; +import { Table, tableTag } from '../../../table'; +import { + controlLabelFont, + controlLabelFontColor +} from '../../../theme-provider/design-tokens'; + +const metadata: Meta = { + title: 'Tests/Table Column Types', + parameters: { + ...sharedMatrixParameters() + } +}; + +export default metadata; + +const data = [ + { + id: '0', + date: new Date('Dec 21, 2020, 3:45:03 PM').valueOf() + }, + { + id: '1', + date: new Date('Dec 21, 2020, 3:45:03 PM').valueOf() + }, + { + id: '2' + } +] as const; + +// prettier-ignore +const component = (): ViewTemplate => html` + + <${tableTag} id-field-name="id" style="height: 250px"> + <${tableColumnDateTextTag} + field-name="date" + group-index="0" + > + <${iconUserTag}> + + +`; + +export const tableColumnDateTextThemeMatrix: StoryFn = createMatrixThemeStory( + createMatrix(component) +); + +tableColumnDateTextThemeMatrix.play = async (): Promise => { + await Promise.all( + Array.from(document.querySelectorAll('nimble-table')).map( + async table => { + await table.setData(data); + } + ) + ); +}; diff --git a/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text.spec.ts b/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text.spec.ts new file mode 100644 index 0000000000..bcca68d11e --- /dev/null +++ b/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text.spec.ts @@ -0,0 +1,212 @@ +import { html } from '@microsoft/fast-element'; +import type { Table } from '../../../table'; +import { TableColumnDateText, tableColumnDateTextTag } from '..'; +import { waitForUpdatesAsync } from '../../../testing/async-helpers'; +import { type Fixture, fixture } from '../../../utilities/tests/fixture'; +import type { TableRecord } from '../../../table/types'; +import { TablePageObject } from '../../../table/testing/table.pageobject'; +import { TableColumnDateTextPageObject } from '../testing/table-column-date-text.pageobject'; + +interface SimpleTableRecord extends TableRecord { + field?: number | null; + anotherField?: number | null; +} + +// prettier-ignore +async function setup(): Promise>> { + return fixture>( + html` + <${tableColumnDateTextTag} field-name="field" group-index="0"> + Column 1 + + <${tableColumnDateTextTag} field-name="anotherField"> + Squeeze Column 1 + + ` + ); +} + +describe('TableColumnDateText', () => { + let element: Table; + let connect: () => Promise; + let disconnect: () => Promise; + let tablePageObject: TablePageObject; + let pageObject: TableColumnDateTextPageObject; + + beforeEach(async () => { + ({ element, connect, disconnect } = await setup()); + tablePageObject = new TablePageObject(element); + pageObject = new TableColumnDateTextPageObject(tablePageObject); + }); + + afterEach(async () => { + await disconnect(); + }); + + it('reports column configuration valid', async () => { + await connect(); + await waitForUpdatesAsync(); + + const firstColumn = element.columns[0] as TableColumnDateText; + + expect(firstColumn.checkValidity()).toBeTrue(); + }); + + const badValueData = [ + { description: 'field not present', data: [{ unused: 'foo' }] }, + { description: 'value is null', data: [{ field: null }] }, + { description: 'value is undefined', data: [{ field: undefined }] }, + { + description: 'value is Inf', + data: [{ field: Number.POSITIVE_INFINITY }] + }, + { + description: 'value is -Inf', + data: [{ field: Number.NEGATIVE_INFINITY }] + }, + { description: 'value is NaN', data: [{ field: Number.NaN }] }, + { + description: 'value is MAX_VALUE', + data: [{ field: Number.MAX_VALUE }] + }, + { + description: 'value is too large for Date', + data: [{ field: 8640000000000000 + 1 }] + }, + { + description: 'value is too small for Date', + data: [{ field: -8640000000000000 - 1 }] + }, + { + description: 'value is not a number', + data: [{ field: 'foo' as unknown as number }] + } + ]; + for (const testData of badValueData) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + it(`displays blank when ${testData.description}`, async () => { + await element.setData(testData.data); + await connect(); + await waitForUpdatesAsync(); + + expect(pageObject.getRenderedCellContent(0, 0)).toEqual(''); + }); + } + + it('changing fieldName updates display', async () => { + await element.setData([ + { + field: new Date('Dec 10, 2012, 10:35:05 PM').valueOf(), + anotherField: new Date('Jan 20, 2018, 4:05:45 AM').valueOf() + } + ]); + await connect(); + await waitForUpdatesAsync(); + + const firstColumn = element.columns[0] as TableColumnDateText; + firstColumn.fieldName = 'anotherField'; + await waitForUpdatesAsync(); + + expect(pageObject.getRenderedCellContent(0, 0)).toEqual( + 'Jan 20, 2018, 4:05:45 AM' + ); + }); + + it('changing data from value to null displays blank', async () => { + await element.setData([ + { field: new Date('Dec 10, 2012, 10:35:05 PM').valueOf() } + ]); + await connect(); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toEqual( + 'Dec 10, 2012, 10:35:05 PM' + ); + + const updatedValue = { field: null }; + const updatedData = [updatedValue]; + await element.setData(updatedData); + await waitForUpdatesAsync(); + + expect(pageObject.getRenderedCellContent(0, 0)).toEqual(''); + }); + + it('changing data from null to value displays value', async () => { + await element.setData([{ field: null }]); + await connect(); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toEqual(''); + + await element.setData([ + { field: new Date('Dec 10, 2012, 10:35:05 PM').valueOf() } + ]); + await waitForUpdatesAsync(); + + expect(pageObject.getRenderedCellContent(0, 0)).toEqual( + 'Dec 10, 2012, 10:35:05 PM' + ); + }); + + it('when no fieldName provided, nothing is displayed', async () => { + await connect(); + await waitForUpdatesAsync(); + + const firstColumn = element.columns[0] as TableColumnDateText; + firstColumn.fieldName = undefined; + await element.setData([ + { field: new Date('Dec 10, 2012, 10:35:05 PM').valueOf() } + ]); + await waitForUpdatesAsync(); + + expect(pageObject.getRenderedCellContent(0, 0)).toEqual(''); + }); + + it('sets title when cell text is ellipsized', async () => { + element.style.width = '200px'; + await element.setData([ + { field: new Date('Dec 10, 2012, 10:35:05 PM').valueOf() } + ]); + await connect(); + await waitForUpdatesAsync(); + tablePageObject.dispatchEventToCell(0, 0, new MouseEvent('mouseover')); + await waitForUpdatesAsync(); + expect(pageObject.getCellTitle(0, 0)).toEqual( + 'Dec 10, 2012, 10:35:05 PM' + ); + }); + + it('does not set title when cell text is fully visible', async () => { + await element.setData([ + { field: new Date('Dec 10, 2012, 10:35:05 PM').valueOf() } + ]); + await connect(); + await waitForUpdatesAsync(); + tablePageObject.dispatchEventToCell(0, 0, new MouseEvent('mouseover')); + await waitForUpdatesAsync(); + expect(pageObject.getCellTitle(0, 0)).toEqual(''); + }); + + it('removes title on mouseout of cell', async () => { + element.style.width = '200px'; + await element.setData([ + { field: new Date('Dec 10, 2012, 10:35:05 PM').valueOf() } + ]); + await connect(); + await waitForUpdatesAsync(); + tablePageObject.dispatchEventToCell(0, 0, new MouseEvent('mouseover')); + await waitForUpdatesAsync(); + tablePageObject.dispatchEventToCell(0, 0, new MouseEvent('mouseout')); + await waitForUpdatesAsync(); + expect(pageObject.getCellTitle(0, 0)).toEqual(''); + }); + + it('sets group header text as to rendered date value', async () => { + await element.setData([ + { field: new Date('Dec 10, 2012, 10:35:05 PM').valueOf() } + ]); + await connect(); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedGroupHeaderContent(0)).toBe( + 'Dec 10, 2012, 10:35:05 PM' + ); + }); +}); diff --git a/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text.stories.ts b/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text.stories.ts new file mode 100644 index 0000000000..c4709bfe1d --- /dev/null +++ b/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text.stories.ts @@ -0,0 +1,121 @@ +import { html, ref } from '@microsoft/fast-element'; +import type { Meta, StoryObj } from '@storybook/html'; +import { withActions } from '@storybook/addon-actions/decorator'; +import { + createUserSelectedThemeStory, + incubatingWarning +} from '../../../utilities/tests/storybook'; +import { tableTag } from '../../../table'; +import { tableColumnDateTextTag } from '..'; +import { + SharedTableArgs, + sharedTableActions, + sharedTableArgTypes, + sharedTableArgs +} from '../../base/tests/table-column-stories-utils'; +import { tableColumnTextTag } from '../../text'; + +const simpleData = [ + { + firstName: 'Homer', + lastName: 'Simpson', + birthday: new Date(1984, 4, 12, 14, 34, 19, 377).valueOf() + }, + { + firstName: 'Marge', + lastName: 'Simpson', + birthday: new Date(1984, 2, 19, 7, 6, 48, 584).valueOf() + }, + { + firstName: 'Bart', + lastName: 'Simpson', + birthday: new Date(2013, 3, 1, 20, 4, 37, 975).valueOf() + }, + { + firstName: 'Maggie', + lastName: 'Simpson', + birthday: new Date(2022, 0, 12, 20, 4, 37, 975).valueOf() + } +]; + +const overviewText = `This page contains information about the types of columns that can be displayed in a \`nimble-table\`. +See the **Table** page for information about configuring the table itself and the **Table Column Configuration** page for +information about common column configuration.`; + +const metadata: Meta = { + title: 'Incubating/Table Column Types', + decorators: [withActions], + parameters: { + docs: { + description: { + component: overviewText + } + }, + actions: { + handles: sharedTableActions + } + }, + // prettier-ignore + argTypes: { + ...sharedTableArgTypes, + selectionMode: { + table: { + disable: true + } + }, + }, + args: { + ...sharedTableArgs(simpleData) + } +}; + +export default metadata; + +interface TextColumnTableArgs extends SharedTableArgs { + fieldName: string; +} + +const dateTextColumnDescription = 'The `nimble-table-column-date-text` column is used to display date-time fields as text in the `nimble-table`. The date-time values must be of type `number` and represent the number of milliseconds since January 1, 1970 UTC. This is the representation used by the [JavaScript `Date` type](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date).'; + +export const dateTextColumn: StoryObj = { + parameters: { + docs: { + description: { + story: dateTextColumnDescription + } + } + }, + // prettier-ignore + render: createUserSelectedThemeStory(html` + ${incubatingWarning({ + componentName: 'table', + statusLink: 'https://github.com/orgs/ni/projects/7/views/21' + })} + <${tableTag} + ${ref('tableRef')} + data-unused="${x => x.updateData(x)}" + > + <${tableColumnTextTag} + field-name="${x => x.fieldName}" + > + Name + + <${tableColumnDateTextTag} + field-name="birthday" + > + Birthday + + + `), + argTypes: { + fieldName: { + name: 'field-name', + description: + 'Set this attribute to identify which field in the data record should be displayed in each column. The field values must be of type `number` and represent the number of milliseconds since January 1, 1970 UTC. This is the representation used by the `Date` type.', + control: { type: 'none' } + } + }, + args: { + fieldName: 'firstName' + } +}; diff --git a/packages/nimble-components/src/table-column/text/tests/table-column-text.spec.ts b/packages/nimble-components/src/table-column/text/tests/table-column-text.spec.ts index 7a43b4ccd1..6307f1c43a 100644 --- a/packages/nimble-components/src/table-column/text/tests/table-column-text.spec.ts +++ b/packages/nimble-components/src/table-column/text/tests/table-column-text.spec.ts @@ -54,7 +54,11 @@ describe('TableColumnText', () => { const noValueData = [ { description: 'field not present', data: [{ unused: 'foo' }] }, { description: 'value is null', data: [{ field: null }] }, - { description: 'value is undefined', data: [{ field: undefined }] } + { description: 'value is undefined', data: [{ field: undefined }] }, + { + description: 'value is not a string', + data: [{ field: 10 as unknown as string }] + } ]; for (const testData of noValueData) { // eslint-disable-next-line @typescript-eslint/no-loop-func diff --git a/packages/nimble-components/src/table/types.ts b/packages/nimble-components/src/table/types.ts index 8ff12f73ed..89da48cd5e 100644 --- a/packages/nimble-components/src/table/types.ts +++ b/packages/nimble-components/src/table/types.ts @@ -18,6 +18,12 @@ export type TableFieldValue = string | number | boolean | null | undefined; */ export type TableStringFieldValue = string | null | undefined; +/** + * TableNumberFieldValue describes the type associated with values within + * a table's number records. + */ +export type TableNumberFieldValue = number | null | undefined; + /** * TableRecord describes the data structure that backs a single row in a table. * It is made up of fields, which are key/value pairs that have a key of type @@ -31,6 +37,10 @@ export type TableStringField = { [name in FieldName]: TableStringFieldValue; }; +export type TableNumberField = { + [name in FieldName]: TableNumberFieldValue; +}; + export interface ValidityObject { [key: string]: boolean; }