From 8a81690d830b69ff3801e03cb6efd327f66117a8 Mon Sep 17 00:00:00 2001 From: mollykreis <20542556+mollykreis@users.noreply.github.com> Date: Mon, 18 Mar 2024 13:03:03 -0500 Subject: [PATCH 01/18] first pass --- .../table-column/anchor/cell-view/index.ts | 11 + .../table-column/anchor/cell-view/styles.ts | 4 + .../table-column/anchor/cell-view/template.ts | 1 + .../src/table-column/anchor/index.ts | 15 +- .../anchor/tests/table-column-anchor.spec.ts | 335 +++++++++++++----- .../tests/table-column-anchor.stories.ts | 3 + .../src/table-column/date-text/index.ts | 8 +- .../tests/table-column-date-text.spec.ts | 303 +++++++++------- .../tests/table-column-date-text.stories.ts | 5 +- .../duration-text/cell-view/index.ts | 11 +- .../src/table-column/duration-text/index.ts | 18 +- .../tests/table-column-duration-text.spec.ts | 122 ++++++- .../table-column-duration-text.stories.ts | 7 +- .../src/table-column/mixins/placeholder.ts | 32 ++ .../number-text/cell-view/index.ts | 11 +- .../src/table-column/number-text/index.ts | 8 +- .../tests/table-column-number-text.spec.ts | 139 ++++++-- .../tests/table-column-number-text.stories.ts | 10 +- .../table-column/text-base/cell-view/index.ts | 21 ++ .../text-base/cell-view/styles.ts | 12 +- .../text-base/cell-view/template.ts | 8 +- .../src/table-column/text-base/index.ts | 3 +- .../src/table-column/text/cell-view/index.ts | 21 +- .../src/table-column/text/index.ts | 12 +- .../text/tests/table-column-text.spec.ts | 131 +++++-- .../text/tests/table-column-text.stories.ts | 5 +- 26 files changed, 923 insertions(+), 333 deletions(-) create mode 100644 packages/nimble-components/src/table-column/mixins/placeholder.ts diff --git a/packages/nimble-components/src/table-column/anchor/cell-view/index.ts b/packages/nimble-components/src/table-column/anchor/cell-view/index.ts index 4d84b21ba8..28de7a34a3 100644 --- a/packages/nimble-components/src/table-column/anchor/cell-view/index.ts +++ b/packages/nimble-components/src/table-column/anchor/cell-view/index.ts @@ -26,17 +26,28 @@ TableColumnAnchorColumnConfig @observable public hasOverflow = false; + /** @internal */ + @observable + public isPlaceholder = false; + /** @internal */ public anchor?: Anchor; @volatile public get text(): string { if (typeof this.cellRecord?.label === 'string') { + this.isPlaceholder = false; return this.cellRecord.label; } if (typeof this.cellRecord?.href === 'string') { + this.isPlaceholder = false; return this.cellRecord.href; } + if (typeof this.columnConfig?.placeholder === 'string') { + this.isPlaceholder = true; + return this.columnConfig?.placeholder; + } + this.isPlaceholder = false; return ''; } diff --git a/packages/nimble-components/src/table-column/anchor/cell-view/styles.ts b/packages/nimble-components/src/table-column/anchor/cell-view/styles.ts index e2dba54296..e80ac26929 100644 --- a/packages/nimble-components/src/table-column/anchor/cell-view/styles.ts +++ b/packages/nimble-components/src/table-column/anchor/cell-view/styles.ts @@ -9,6 +9,10 @@ export const styles = css` align-self: center; } + :host(.placeholder) { + opacity: 0.6; + } + nimble-anchor { white-space: nowrap; overflow: hidden; diff --git a/packages/nimble-components/src/table-column/anchor/cell-view/template.ts b/packages/nimble-components/src/table-column/anchor/cell-view/template.ts index d65f7f03bd..d369615412 100644 --- a/packages/nimble-components/src/table-column/anchor/cell-view/template.ts +++ b/packages/nimble-components/src/table-column/anchor/cell-view/template.ts @@ -13,6 +13,7 @@ export const template = html` } return true; }}" + class="${x => (x.isPlaceholder ? 'placeholder' : '')}" > ${when(x => typeof x.cellRecord?.href === 'string', html` <${anchorTag} diff --git a/packages/nimble-components/src/table-column/anchor/index.ts b/packages/nimble-components/src/table-column/anchor/index.ts index 732362f2cc..4f15d7034c 100644 --- a/packages/nimble-components/src/table-column/anchor/index.ts +++ b/packages/nimble-components/src/table-column/anchor/index.ts @@ -6,6 +6,7 @@ import { template } from '../base/template'; import { TableColumnSortOperation } from '../base/types'; import { mixinFractionalWidthColumnAPI } from '../mixins/fractional-width-column'; import { mixinGroupableColumnAPI } from '../mixins/groupable-column'; +import { mixinColumnWithPlaceholderAPI } from '../mixins/placeholder'; import type { TableStringField } from '../../table/types'; import { tableColumnAnchorCellViewTag } from './cell-view'; import { tableColumnTextGroupHeaderViewTag } from '../text/group-header-view'; @@ -23,6 +24,7 @@ export interface TableColumnAnchorColumnConfig { target?: string; type?: string; download?: string; + placeholder?: string; } declare global { @@ -35,7 +37,11 @@ declare global { * A table column for displaying links. */ export class TableColumnAnchor extends mixinGroupableColumnAPI( - mixinFractionalWidthColumnAPI(TableColumn) + mixinFractionalWidthColumnAPI( + mixinColumnWithPlaceholderAPI( + TableColumn + ) + ) ) { @attr({ attribute: 'label-field-name' }) public labelFieldName?: string; @@ -70,6 +76,10 @@ export class TableColumnAnchor extends mixinGroupableColumnAPI( @attr public download?: string; + public placeholderChanged(): void { + this.updateColumnConfig(); + } + protected override getColumnInternalsOptions(): ColumnInternalsOptions { return { cellRecordFieldNames: ['label', 'href'], @@ -141,7 +151,8 @@ export class TableColumnAnchor extends mixinGroupableColumnAPI( rel: this.rel, target: this.target, type: this.type, - download: this.download + download: this.download, + placeholder: this.placeholder }; } } 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 89a8ad203c..694b24b843 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 @@ -1,6 +1,6 @@ -import { html } from '@microsoft/fast-element'; +import { html, ref } from '@microsoft/fast-element'; import { parameterizeSpec } from '@ni/jasmine-parameterized'; -import type { Table } from '../../../table'; +import { tableTag, type Table } from '../../../table'; import { TableColumnAnchor, tableColumnAnchorTag } from '..'; import { waitForUpdatesAsync } from '../../../testing/async-helpers'; import { type Fixture, fixture } from '../../../utilities/tests/fixture'; @@ -8,6 +8,7 @@ import { TableColumnSortDirection, TableRecord } from '../../../table/types'; import { TablePageObject } from '../../../table/testing/table.pageobject'; import { wackyStrings } from '../../../utilities/tests/wacky-strings'; import type { Anchor } from '../../../anchor'; +import { themeProviderTag } from '../../../theme-provider'; interface SimpleTableRecord extends TableRecord { label?: string | null; @@ -16,41 +17,54 @@ interface SimpleTableRecord extends TableRecord { otherLink?: string | null; } +class ElementReferences { + public table!: Table; + public column!: TableColumnAnchor; +} + // prettier-ignore -async function setup(): Promise>> { +async function setup(source: ElementReferences): Promise>> { return fixture>( - html` - <${tableColumnAnchorTag} - label-field-name="label" - href-field-name="link" - appearance="prominent" - hreflang="hreflang value" - ping="ping value" - referrerpolicy="referrerpolicy value" - rel="rel value" - target="target value" - type="type value" - download="download value" - group-index="0" - > - Column 1 - - <${tableColumnAnchorTag}> - Column 2 - - ` + html`<${themeProviderTag} lang="en-US"> + <${tableTag} style="width: 700px" ${ref('table')}> + <${tableColumnAnchorTag} + ${ref('column')} + label-field-name="label" + href-field-name="link" + appearance="prominent" + hreflang="hreflang value" + ping="ping value" + referrerpolicy="referrerpolicy value" + rel="rel value" + target="target value" + type="type value" + download="download value" + group-index="0" + > + Column 1 + + <${tableColumnAnchorTag}> + Column 2 + + + `, + { source } ); } describe('TableColumnAnchor', () => { - let element: Table; + let table: Table; + let column: TableColumnAnchor; let connect: () => Promise; let disconnect: () => Promise; let pageObject: TablePageObject; beforeEach(async () => { - ({ element, connect, disconnect } = await setup()); - pageObject = new TablePageObject(element); + const elementReferences = new ElementReferences(); + ({ connect, disconnect } = await setup(elementReferences)); + table = elementReferences.table; + column = elementReferences.column; + pageObject = new TablePageObject(table); }); afterEach(async () => { @@ -71,62 +85,40 @@ describe('TableColumnAnchor', () => { await connect(); await waitForUpdatesAsync(); - const firstColumn = element.columns[0] as TableColumnAnchor; - - expect(firstColumn.checkValidity()).toBeTrue(); + expect(column.checkValidity()).toBeTrue(); }); describe('with no href', () => { - const noValueData = [ - { name: 'field not present', data: [{ unused: 'foo' }] }, - { name: 'value is null', data: [{ label: null }] }, - { name: 'value is undefined', data: [{ label: undefined }] }, - { - name: 'value is not a string', - data: [{ label: 10 as unknown as string }] - } - ] as const; - parameterizeSpec(noValueData, (spec, name, value) => { - spec(`displays empty string when label ${name}`, async () => { - await element.setData(value.data); - await connect(); - await waitForUpdatesAsync(); - - expect(pageObject.getRenderedCellTextContent(0, 0)).toBe(''); - }); - }); - it('changing labelFieldName updates display', async () => { - await element.setData([{ label: 'foo', otherLabel: 'bar' }]); + await table.setData([{ label: 'foo', otherLabel: 'bar' }]); await connect(); await waitForUpdatesAsync(); - const firstColumn = element.columns[0] as TableColumnAnchor; - firstColumn.labelFieldName = 'otherLabel'; + column.labelFieldName = 'otherLabel'; await waitForUpdatesAsync(); expect(pageObject.getRenderedCellTextContent(0, 0)).toBe('bar'); }); it('changing data from value to null displays blank', async () => { - await element.setData([{ label: 'foo' }]); + await table.setData([{ label: 'foo' }]); await connect(); await waitForUpdatesAsync(); expect(pageObject.getRenderedCellTextContent(0, 0)).toBe('foo'); - await element.setData([{ label: null }]); + await table.setData([{ label: null }]); await waitForUpdatesAsync(); expect(pageObject.getRenderedCellTextContent(0, 0)).toBe(''); }); it('changing data from null to value displays value', async () => { - await element.setData([{ label: null }]); + await table.setData([{ label: null }]); await connect(); await waitForUpdatesAsync(); expect(pageObject.getRenderedCellTextContent(0, 0)).toBe(''); - await element.setData([{ label: 'foo' }]); + await table.setData([{ label: 'foo' }]); await waitForUpdatesAsync(); expect(pageObject.getRenderedCellTextContent(0, 0)).toBe('foo'); @@ -136,9 +128,8 @@ describe('TableColumnAnchor', () => { await connect(); await waitForUpdatesAsync(); - const firstColumn = element.columns[0] as TableColumnAnchor; - firstColumn.labelFieldName = undefined; - await element.setData([{ field: 'foo' }]); + column.labelFieldName = undefined; + await table.setData([{ field: 'foo' }]); await waitForUpdatesAsync(); expect(pageObject.getRenderedCellTextContent(0, 0)).toBe(''); @@ -146,7 +137,7 @@ describe('TableColumnAnchor', () => { it('sets title when cell text is ellipsized', async () => { const cellContents = 'a very long value that should get ellipsized due to not fitting within the default cell width'; - await element.setData([{ label: cellContents }]); + await table.setData([{ label: cellContents }]); await connect(); await waitForUpdatesAsync(); pageObject.dispatchEventToCell(0, 0, new MouseEvent('mouseover')); @@ -156,7 +147,7 @@ describe('TableColumnAnchor', () => { it('does not set title when cell text is fully visible', async () => { const cellContents = 'short value'; - await element.setData([{ label: cellContents }]); + await table.setData([{ label: cellContents }]); await connect(); await waitForUpdatesAsync(); pageObject.dispatchEventToCell(0, 0, new MouseEvent('mouseover')); @@ -166,7 +157,7 @@ describe('TableColumnAnchor', () => { it('removes title on mouseout of cell', async () => { const cellContents = 'a very long value that should get ellipsized due to not fitting within the default cell width'; - await element.setData([{ label: cellContents }]); + await table.setData([{ label: cellContents }]); await connect(); await waitForUpdatesAsync(); pageObject.dispatchEventToCell(0, 0, new MouseEvent('mouseover')); @@ -181,7 +172,7 @@ describe('TableColumnAnchor', () => { spec(`data "${name}" renders correctly`, async () => { await connect(); - await element.setData([{ label: name }]); + await table.setData([{ label: name }]); await waitForUpdatesAsync(); expect(pageObject.getRenderedCellTextContent(0, 0)).toBe( @@ -194,7 +185,7 @@ describe('TableColumnAnchor', () => { describe('with href', () => { it('displays label when href is not string', async () => { - await element.setData([ + await table.setData([ { label: 'foo', link: 10 as unknown as string } ]); await connect(); @@ -204,33 +195,31 @@ describe('TableColumnAnchor', () => { }); it('changing labelFieldName updates display', async () => { - await element.setData([ + await table.setData([ { label: 'foo', otherLabel: 'bar', link: 'url' } ]); await connect(); await waitForUpdatesAsync(); - const firstColumn = element.columns[0] as TableColumnAnchor; - firstColumn.labelFieldName = 'otherLabel'; + column.labelFieldName = 'otherLabel'; await waitForUpdatesAsync(); expect(pageObject.getRenderedCellTextContent(0, 0)).toBe('bar'); }); it('changing hrefFieldName updates href', async () => { - await element.setData([{ link: 'foo', otherLink: 'bar' }]); + await table.setData([{ link: 'foo', otherLink: 'bar' }]); await connect(); await waitForUpdatesAsync(); - const firstColumn = element.columns[0] as TableColumnAnchor; - firstColumn.hrefFieldName = 'otherLink'; + column.hrefFieldName = 'otherLink'; await waitForUpdatesAsync(); expect(pageObject.getRenderedCellAnchor(0, 0).href).toBe('bar'); }); it('sets appearance on anchor', async () => { - await element.setData([{ link: 'foo' }]); + await table.setData([{ link: 'foo' }]); await connect(); await waitForUpdatesAsync(); @@ -240,12 +229,11 @@ describe('TableColumnAnchor', () => { }); it('updating underline-hidden from true to false removes the underline-hidden attribute from the anchor', async () => { - await element.setData([{ link: 'foo' }]); + await table.setData([{ link: 'foo' }]); await connect(); await waitForUpdatesAsync(); - const firstColumn = element.columns[0] as TableColumnAnchor; - firstColumn.underlineHidden = true; + column.underlineHidden = true; await waitForUpdatesAsync(); expect( pageObject @@ -253,7 +241,7 @@ describe('TableColumnAnchor', () => { .hasAttribute('underline-hidden') ).toBeTrue(); - firstColumn.underlineHidden = false; + column.underlineHidden = false; await waitForUpdatesAsync(); expect( pageObject @@ -276,7 +264,7 @@ describe('TableColumnAnchor', () => { ] as const; parameterizeSpec(linkOptionData, (spec, name, value) => { spec(`sets ${name} on anchor`, async () => { - await element.setData([{ link: 'foo' }]); + await table.setData([{ link: 'foo' }]); await connect(); await waitForUpdatesAsync(); @@ -288,7 +276,7 @@ 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 table.setData([{ link: 10 as unknown as string }]); await connect(); await waitForUpdatesAsync(); @@ -296,7 +284,7 @@ describe('TableColumnAnchor', () => { }); it('displays url', async () => { - await element.setData([{ link: 'foo' }]); + await table.setData([{ link: 'foo' }]); await connect(); await waitForUpdatesAsync(); @@ -304,24 +292,24 @@ describe('TableColumnAnchor', () => { }); it('changing url from value to null displays blank', async () => { - await element.setData([{ link: 'foo' }]); + await table.setData([{ link: 'foo' }]); await connect(); await waitForUpdatesAsync(); expect(pageObject.getRenderedCellTextContent(0, 0)).toBe('foo'); - await element.setData([{ link: null }]); + await table.setData([{ link: null }]); await waitForUpdatesAsync(); expect(pageObject.getRenderedCellTextContent(0, 0)).toBe(''); }); it('changing url from null to value displays value', async () => { - await element.setData([{ link: null }]); + await table.setData([{ link: null }]); await connect(); await waitForUpdatesAsync(); expect(pageObject.getRenderedCellTextContent(0, 0)).toBe(''); - await element.setData([{ link: 'foo' }]); + await table.setData([{ link: 'foo' }]); await waitForUpdatesAsync(); expect(pageObject.getRenderedCellTextContent(0, 0)).toBe('foo'); @@ -330,7 +318,7 @@ describe('TableColumnAnchor', () => { it('sets title when cell text is ellipsized', async () => { const cellContents = 'a very long value that should get ellipsized due to not fitting within the default cell width'; - await element.setData([{ label: cellContents, link: 'url' }]); + await table.setData([{ label: cellContents, link: 'url' }]); await connect(); await waitForUpdatesAsync(); pageObject.dispatchEventToCell(0, 0, new MouseEvent('mouseover')); @@ -340,7 +328,7 @@ describe('TableColumnAnchor', () => { it('does not set title when cell text is fully visible', async () => { const cellContents = 'short value'; - await element.setData([{ label: cellContents, link: 'url' }]); + await table.setData([{ label: cellContents, link: 'url' }]); await connect(); await waitForUpdatesAsync(); pageObject.dispatchEventToCell(0, 0, new MouseEvent('mouseover')); @@ -350,7 +338,7 @@ describe('TableColumnAnchor', () => { it('removes title on mouseout of cell', async () => { const cellContents = 'a very long value that should get ellipsized due to not fitting within the default cell width'; - await element.setData([{ label: cellContents, link: 'url' }]); + await table.setData([{ label: cellContents, link: 'url' }]); await connect(); await waitForUpdatesAsync(); pageObject.dispatchEventToCell(0, 0, new MouseEvent('mouseover')); @@ -362,8 +350,8 @@ describe('TableColumnAnchor', () => { it('sets title when group header text is ellipsized', async () => { const cellContents = 'a very long value that should get ellipsized due to not fitting within the default cell width'; - await element.setData([{ label: cellContents, link: 'url' }]); - element.style.width = '200px'; + await table.setData([{ label: cellContents, link: 'url' }]); + table.style.width = '200px'; await connect(); await waitForUpdatesAsync(); pageObject.dispatchEventToGroupHeader( @@ -376,7 +364,7 @@ describe('TableColumnAnchor', () => { it('does not set title when group header text is fully visible', async () => { const cellContents = 'foo'; - await element.setData([{ label: cellContents, link: 'url' }]); + await table.setData([{ label: cellContents, link: 'url' }]); await connect(); await waitForUpdatesAsync(); pageObject.dispatchEventToGroupHeader( @@ -389,7 +377,7 @@ describe('TableColumnAnchor', () => { it('removes title on mouseout of group header', async () => { const cellContents = 'a very long value that should get ellipsized due to not fitting within the default cell width'; - await element.setData([{ label: cellContents, link: 'url' }]); + await table.setData([{ label: cellContents, link: 'url' }]); await connect(); await waitForUpdatesAsync(); pageObject.dispatchEventToGroupHeader( @@ -406,14 +394,13 @@ describe('TableColumnAnchor', () => { }); it('sorts by the label field', async () => { - element.idFieldName = 'label'; + table.idFieldName = 'label'; await connect(); await waitForUpdatesAsync(); - const firstColumn = element.columns[0] as TableColumnAnchor; - firstColumn.sortDirection = TableColumnSortDirection.ascending; - firstColumn.sortIndex = 0; - await element.setData([ + column.sortDirection = TableColumnSortDirection.ascending; + column.sortIndex = 0; + await table.setData([ { label: 'd', link: 'foo3' }, { label: 'a', link: 'foo4' }, { label: 'c', link: 'foo1' }, @@ -440,7 +427,7 @@ describe('TableColumnAnchor', () => { spec(`data "${name}" renders correctly`, async () => { await connect(); - await element.setData([{ label: name, link: 'url' }]); + await table.setData([{ label: name, link: 'url' }]); await waitForUpdatesAsync(); expect(pageObject.getRenderedCellTextContent(0, 0)).toBe( @@ -455,7 +442,7 @@ describe('TableColumnAnchor', () => { spec(`data "${name}" renders correctly`, async () => { await connect(); - await element.setData([{ label: name, link: 'url' }]); + await table.setData([{ label: name, link: 'url' }]); await waitForUpdatesAsync(); expect( @@ -465,4 +452,160 @@ describe('TableColumnAnchor', () => { }); }); }); -}); + + describe('placeholder', () => { + const testCases = [ + { + name: 'label and href are both defined', + data: [{ label: 'my label', link: 'url' }], + cellValue: 'my label', + groupValue: 'my label', + usesColumnPlaceholder: false + }, + { + name: 'label and href are both missing', + data: [{}], + cellValue: '', + groupValue: 'No alias', + usesColumnPlaceholder: true + }, + { + name: 'label and href are both undefined', + data: [{ label: undefined, link: undefined }], + cellValue: '', + groupValue: 'No alias', + usesColumnPlaceholder: true + }, + { + name: 'label and href are both null', + data: [{ label: null, link: null }], + cellValue: '', + groupValue: 'No alias', + usesColumnPlaceholder: true + }, + { + name: 'label is null and href is undefined', + data: [{ label: null, link: undefined }], + cellValue: '', + groupValue: 'No alias', + usesColumnPlaceholder: true + }, + { + name: 'label is undefined and href is null', + data: [{ label: undefined, link: null }], + cellValue: '', + groupValue: 'No alias', + usesColumnPlaceholder: true + }, + { + name: 'label is empty with defined href', + data: [{ label: '', link: 'link' }], + cellValue: '', + groupValue: 'Empty', + usesColumnPlaceholder: false + }, + { + name: 'label is non-empty with undefined href', + data: [{ label: 'my label', link: undefined }], + cellValue: 'my label', + groupValue: 'my label', + usesColumnPlaceholder: false + }, + { + name: 'label is not a string', + data: [{ label: 10 as unknown as string }], + cellValue: '', + groupValue: '', + usesColumnPlaceholder: false + } + ]; + + parameterizeSpec(testCases, (spec, name, value) => { + spec( + `cell and group row render expected value when ${name} and placeholder is configured`, + async () => { + const placeholder = 'Custom placeholder'; + column.placeholder = placeholder; + await table.setData(value.data); + await connect(); + await waitForUpdatesAsync(); + + const expectedCellText = value.usesColumnPlaceholder + ? placeholder + : value.cellValue; + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe( + expectedCellText + ); + expect( + pageObject.getRenderedGroupHeaderTextContent(0) + ).toBe(value.groupValue); + } + ); + }); + + parameterizeSpec(testCases, (spec, name, value) => { + spec( + `cell and group row render expected value when ${name} and placeholder is not configured`, + async () => { + await table.setData(value.data); + await connect(); + await waitForUpdatesAsync(); + + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe( + value.cellValue + ); + expect( + pageObject.getRenderedGroupHeaderTextContent(0) + ).toBe(value.groupValue); + } + ); + }); + + it('setting placeholder to undefined updates cells from displaying placeholder to displaying blank', async () => { + const placeholder = 'My placeholder'; + column.placeholder = placeholder; + await table.setData([{}]); + await connect(); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe( + placeholder + ); + + column.placeholder = undefined; + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe(''); + }); + + it('setting placeholder to defined string updates cells from displaying placeholder to displaying blank', async () => { + await table.setData([{}]); + await connect(); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe(''); + + const placeholder = 'placeholder'; + column.placeholder = placeholder; + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe( + placeholder + ); + }); + + it('updating placeholder from one string to another updates cell', async () => { + const placeholder1 = 'My first placeholder'; + column.placeholder = placeholder1; + await table.setData([{}]); + await connect(); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe( + placeholder1 + ); + + const placeholder2 = 'My second placeholder'; + column.placeholder = placeholder2; + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe( + placeholder2 + ); + }); + }); +}); \ No newline at end of file diff --git a/packages/nimble-components/src/table-column/anchor/tests/table-column-anchor.stories.ts b/packages/nimble-components/src/table-column/anchor/tests/table-column-anchor.stories.ts index 4cdca5af2f..b2a8caed01 100644 --- a/packages/nimble-components/src/table-column/anchor/tests/table-column-anchor.stories.ts +++ b/packages/nimble-components/src/table-column/anchor/tests/table-column-anchor.stories.ts @@ -69,6 +69,7 @@ interface AnchorColumnTableArgs extends SharedTableArgs { hrefFieldName: string; appearance: keyof typeof AnchorAppearance; underlineHidden: boolean; + placeholder: string; } export const anchorColumn: StoryObj = { @@ -84,6 +85,7 @@ export const anchorColumn: StoryObj = { href-field-name="${x => x.hrefFieldName}" appearance="${x => x.appearance}" ?underline-hidden="${x => x.underlineHidden}" + placeholder="${x => x.placeholder}" > Link Column @@ -125,6 +127,7 @@ export const anchorColumn: StoryObj = { hrefFieldName: 'url', appearance: 'default', underlineHidden: false, + placeholder: 'Unknown value', ...sharedTableArgs(simpleData) } }; diff --git a/packages/nimble-components/src/table-column/date-text/index.ts b/packages/nimble-components/src/table-column/date-text/index.ts index 578b1da9d8..a3fa46bf79 100644 --- a/packages/nimble-components/src/table-column/date-text/index.ts +++ b/packages/nimble-components/src/table-column/date-text/index.ts @@ -36,6 +36,7 @@ import { optionalBooleanConverter } from '../../utilities/models/converter'; export type TableColumnDateTextCellRecord = TableNumberField<'value'>; export interface TableColumnDateTextColumnConfig { formatter: Intl.DateTimeFormat; + placeholder?: string; } declare global { @@ -135,6 +136,10 @@ export class TableColumnDateText extends TableColumnTextBase { return this.validator.getValidity(); } + public placeholderChanged(): void { + this.updateColumnConfig(); + } + protected override getColumnInternalsOptions(): ColumnInternalsOptions { return { cellRecordFieldNames: ['value'], @@ -230,7 +235,8 @@ export class TableColumnDateText extends TableColumnTextBase { if (formatter) { const columnConfig: TableColumnDateTextColumnConfig = { - formatter + formatter, + placeholder: this.placeholder }; this.columnInternals.columnConfig = columnConfig; this.validator.setCustomOptionsValidity(true); 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 index 9bcaefed03..4af4b7a4da 100644 --- 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 @@ -78,48 +78,6 @@ describe('TableColumnDateText', () => { expect(column.checkValidity()).toBeTrue(); }); - describe('displays blank when', () => { - const badValueData = [ - { name: 'field not present', data: [{ unused: 'foo' }] }, - { name: 'value is null', data: [{ field: null }] }, - { name: 'value is undefined', data: [{ field: undefined }] }, - { - name: 'value is Inf', - data: [{ field: Number.POSITIVE_INFINITY }] - }, - { - name: 'value is -Inf', - data: [{ field: Number.NEGATIVE_INFINITY }] - }, - { name: 'value is NaN', data: [{ field: Number.NaN }] }, - { - name: 'value is MAX_VALUE', - data: [{ field: Number.MAX_VALUE }] - }, - { - name: 'value is too large for Date', - data: [{ field: 8640000000000000 + 1 }] - }, - { - name: 'value is too small for Date', - data: [{ field: -8640000000000000 - 1 }] - }, - { - name: 'value is not a number', - data: [{ field: 'foo' as unknown as number }] - } - ] as const; - - parameterizeSpec(badValueData, (spec, name, value) => { - spec(name, async () => { - await table.setData(value.data); - await waitForUpdatesAsync(); - - expect(pageObject.getRenderedCellContent(0, 0)).toEqual(''); - }); - }); - }); - // WebKit skipped, see https://github.com/ni/nimble/issues/1940 it('changing fieldName updates display #SkipWebkit', async () => { await table.setData([ @@ -518,86 +476,189 @@ describe('TableColumnDateText', () => { }); describe('placeholder', () => { - const testCases = [ - { - name: 'value is not specified', - data: [{}], - groupValue: 'No value' - }, - { - name: 'value is undefined', - data: [{ field: undefined }], - groupValue: 'No value' - }, - { - name: 'value is null', - data: [{ field: null }], - groupValue: 'No value' - }, - { - name: 'value is Number.NaN', - data: [{ field: Number.NaN }], - groupValue: '' - }, - { - name: 'value is valid and non-zero', - data: [{ field: 1708984169258 }], - groupValue: '2/26/2024' - }, - { - name: 'value is incorrect type', - data: [{ field: 'not a number' as unknown as number }], - groupValue: '' - }, - { - name: 'value is specified and falsey', - data: [{ field: 0 }], - groupValue: '1/1/1970' - }, - { - name: 'value is Inf', - data: [{ field: Number.POSITIVE_INFINITY }], - groupValue: '' - }, - { - name: 'value is -Inf', - data: [{ field: Number.NEGATIVE_INFINITY }], - groupValue: '' - }, - { - name: 'value is MAX_VALUE', - data: [{ field: Number.MAX_VALUE }], - groupValue: '' - }, - { - name: 'value is too large for Date', - data: [{ field: 8640000000000000 + 1 }], - groupValue: '' - }, - { - name: 'value is too small for Date', - data: [{ field: -8640000000000000 - 1 }], - groupValue: '' - } - ]; - - parameterizeSpec(testCases, (spec, name, value) => { - spec( - `group row renders expected value when ${name}`, - async () => { - // Set a custom time zone so that the behavior of the test does not - // depend on the configuration of the computer running the tests. - column.format = DateTextFormat.custom; - column.customTimeZone = 'UTC'; - await table.setData(value.data); - await connect(); - await waitForUpdatesAsync(); - - expect( - pageObject.getRenderedGroupHeaderContent(0) - ).toBe(value.groupValue); + describe('placeholder', () => { + const testCases = [ + { + name: 'value is not specified', + data: [{}], + cellValue: '', + groupValue: 'No value', + usesColumnPlaceholder: true + }, + { + name: 'value is undefined', + data: [{ field: undefined }], + cellValue: '', + groupValue: 'No value', + usesColumnPlaceholder: true + }, + { + name: 'value is null', + data: [{ field: null }], + cellValue: '', + groupValue: 'No value', + usesColumnPlaceholder: true + }, + { + name: 'value is Number.NaN', + data: [{ field: Number.NaN }], + cellValue: '', + groupValue: '', + usesColumnPlaceholder: false + }, + { + name: 'value is valid and non-zero', + data: [{ field: 1708984169258 }], + cellValue: 'Feb 26, 2024, 3:49:29 PM', + groupValue: 'Feb 26, 2024, 3:49:29 PM', + usesColumnPlaceholder: false + }, + { + name: 'value is incorrect type', + data: [{ field: 'not a number' as unknown as number }], + cellValue: '', + groupValue: '', + usesColumnPlaceholder: false + }, + { + name: 'value is specified and falsey', + data: [{ field: 0 }], + cellValue: 'Dec 31, 1969, 6:00:00 PM', + groupValue: 'Dec 31, 1969, 6:00:00 PM', + usesColumnPlaceholder: false + }, + { + name: 'value is Inf', + data: [{ field: Number.POSITIVE_INFINITY }], + cellValue: '', + groupValue: '', + usesColumnPlaceholder: false + }, + { + name: 'value is -Inf', + data: [{ field: Number.NEGATIVE_INFINITY }], + cellValue: '', + groupValue: '', + usesColumnPlaceholder: false + }, + { + name: 'value is MAX_VALUE', + data: [{ field: Number.MAX_VALUE }], + cellValue: '', + groupValue: '', + usesColumnPlaceholder: false + }, + { + name: 'value is too large for Date', + data: [{ field: 8640000000000000 + 1 }], + cellValue: '', + groupValue: '', + usesColumnPlaceholder: false + }, + { + name: 'value is too small for Date', + data: [{ field: -8640000000000000 - 1 }], + cellValue: '', + groupValue: '', + usesColumnPlaceholder: false } - ); + ]; + + parameterizeSpec(testCases, (spec, name, value) => { + spec( + `cell and group row render expected value when ${name} and placeholder is configured`, + async () => { + // Set a custom time zone so that the behavior of the test does not + // depend on the configuration of the computer running the tests. + column.format = DateTextFormat.custom; + column.customTimeZone = 'UTC'; + const placeholder = 'Custom placeholder'; + elementReferences.column1.placeholder = placeholder; + await table.setData(value.data); + await connect(); + await waitForUpdatesAsync(); + + const expectedCellText = value.usesColumnPlaceholder + ? placeholder + : value.cellValue; + expect(pageObject.getRenderedCellContent(0, 0)).toBe( + expectedCellText + ); + expect( + pageObject.getRenderedGroupHeaderContent(0) + ).toBe(value.groupValue); + } + ); + }); + + parameterizeSpec(testCases, (spec, name, value) => { + spec( + `cell and group row render expected value when ${name} and placeholder is not configured`, + async () => { + // Set a custom time zone so that the behavior of the test does not + // depend on the configuration of the computer running the tests. + column.format = DateTextFormat.custom; + column.customTimeZone = 'UTC'; + await table.setData(value.data); + await connect(); + await waitForUpdatesAsync(); + + expect(pageObject.getRenderedCellContent(0, 0)).toBe( + value.cellValue + ); + expect( + pageObject.getRenderedGroupHeaderContent(0) + ).toBe(value.groupValue); + } + ); + }); + + it('setting placeholder to undefined updates cells from displaying placeholder to displaying blank', async () => { + const placeholder = 'My placeholder'; + elementReferences.column1.placeholder = placeholder; + await table.setData([{}]); + await connect(); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toBe( + placeholder + ); + + elementReferences.column1.placeholder = undefined; + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toBe(''); + }); + + it('setting placeholder to defined string updates cells from displaying placeholder to displaying blank', async () => { + await table.setData([{}]); + await connect(); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toBe(''); + + const placeholder = 'placeholder'; + elementReferences.column1.placeholder = placeholder; + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toBe( + placeholder + ); + }); + + it('updating placeholder from one string to another updates cell', async () => { + const placeholder1 = 'My first placeholder'; + elementReferences.column1.placeholder = placeholder1; + await table.setData([{}]); + await connect(); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toBe( + placeholder1 + ); + + const placeholder2 = 'My second placeholder'; + elementReferences.column1.placeholder = placeholder2; + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toBe( + placeholder2 + ); + }); }); }); }); 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 index bb6c6796e2..03995e5768 100644 --- 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 @@ -49,7 +49,7 @@ const simpleData = [ { firstName: 'Maggie', lastName: 'Simpson', - birthday: new Date(2022, 0, 12, 20, 4, 37, 975).valueOf() + birthday: undefined } ] as const; @@ -79,6 +79,7 @@ export default metadata; interface TextColumnTableArgs extends SharedTableArgs { fieldName: string; + placeholder: string; format: keyof typeof DateTextFormat; customDateStyle: DateStyle; customTimeStyle: TimeStyle; @@ -123,6 +124,7 @@ export const dateTextColumn: StoryObj = { <${tableColumnDateTextTag} field-name="birthday" + placeholder=${x => x.placeholder}" format="${x => DateTextFormat[x.format]}" custom-date-style="${x => x.customDateStyle}" custom-time-style="${x => x.customTimeStyle}" @@ -333,6 +335,7 @@ export const dateTextColumn: StoryObj = { }, args: { fieldName: 'firstName', + placeholder: 'Unknown value', format: 'default', customDateStyle: undefined, customTimeStyle: undefined, diff --git a/packages/nimble-components/src/table-column/duration-text/cell-view/index.ts b/packages/nimble-components/src/table-column/duration-text/cell-view/index.ts index 0b59bde91b..ffe153bf99 100644 --- a/packages/nimble-components/src/table-column/duration-text/cell-view/index.ts +++ b/packages/nimble-components/src/table-column/duration-text/cell-view/index.ts @@ -29,7 +29,16 @@ TableColumnDurationTextColumnConfig } private updateText(): void { - this.text = this.columnConfig?.formatter.format(this.cellRecord?.value) ?? ''; + const cellValue = this.cellRecord?.value; + if ( + this.applyPlaceholderTextIfNeeded( + cellValue, + this.columnConfig?.placeholder + ) + ) { + return; + } + this.text = this.columnConfig?.formatter.format(cellValue) ?? ''; } } diff --git a/packages/nimble-components/src/table-column/duration-text/index.ts b/packages/nimble-components/src/table-column/duration-text/index.ts index f66b291f1b..37bbe247b0 100644 --- a/packages/nimble-components/src/table-column/duration-text/index.ts +++ b/packages/nimble-components/src/table-column/duration-text/index.ts @@ -16,6 +16,7 @@ import { tableColumnDurationTextGroupHeaderViewTag } from './group-header-view'; export type TableColumnDurationTextCellRecord = TableNumberField<'value'>; export interface TableColumnDurationTextColumnConfig { formatter: DurationFormatter; + placeholder?: string; } declare global { @@ -45,6 +46,10 @@ export class TableColumnDurationText extends TableColumnTextBase { lang.unsubscribe(this.langSubscriber, this); } + public placeholderChanged(): void { + this.updateColumnConfig(); + } + protected override getColumnInternalsOptions(): ColumnInternalsOptions { return { cellRecordFieldNames: ['value'], @@ -58,14 +63,11 @@ export class TableColumnDurationText extends TableColumnTextBase { private updateColumnConfig(): void { const formatter = new DurationFormatter(lang.getValueFor(this)); - if (formatter) { - const columnConfig: TableColumnDurationTextColumnConfig = { - formatter - }; - this.columnInternals.columnConfig = columnConfig; - } else { - this.columnInternals.columnConfig = undefined; - } + const columnConfig: TableColumnDurationTextColumnConfig = { + formatter, + placeholder: this.placeholder + }; + this.columnInternals.columnConfig = columnConfig; } } diff --git a/packages/nimble-components/src/table-column/duration-text/tests/table-column-duration-text.spec.ts b/packages/nimble-components/src/table-column/duration-text/tests/table-column-duration-text.spec.ts index 419d200602..2a55ee08a7 100644 --- a/packages/nimble-components/src/table-column/duration-text/tests/table-column-duration-text.spec.ts +++ b/packages/nimble-components/src/table-column/duration-text/tests/table-column-duration-text.spec.ts @@ -185,60 +185,146 @@ describe('TableColumnDurationText', () => { { name: 'value is not specified', data: [{}], - groupValue: 'No value' + cellValue: '', + groupValue: 'No value', + usesColumnPlaceholder: true }, { name: 'value is undefined', data: [{ field: undefined }], - groupValue: 'No value' + cellValue: '', + groupValue: 'No value', + usesColumnPlaceholder: true }, { name: 'value is null', data: [{ field: null }], - groupValue: 'No value' + cellValue: '', + groupValue: 'No value', + usesColumnPlaceholder: true }, { name: 'value is Number.NaN', data: [{ field: Number.NaN }], - groupValue: '' + cellValue: '', + groupValue: '', + usesColumnPlaceholder: false }, { name: 'value is valid and non-zero', data: [{ field: 20000 }], - groupValue: '20 sec' + cellValue: '20 sec', + groupValue: '20 sec', + usesColumnPlaceholder: false }, { name: 'value is incorrect type', data: [{ field: 'not a number' as unknown as number }], - groupValue: '' + cellValue: '', + groupValue: '', + usesColumnPlaceholder: false }, { name: 'value is specified and falsey', data: [{ field: 0 }], - groupValue: '0 sec' + cellValue: '0 sec', + groupValue: '0 sec', + usesColumnPlaceholder: false }, { name: 'value is Inf', data: [{ field: Number.POSITIVE_INFINITY }], - groupValue: '' + cellValue: '', + groupValue: '', + usesColumnPlaceholder: false }, { name: 'value is negative', data: [{ field: -5 }], - groupValue: '' + cellValue: '', + groupValue: '', + usesColumnPlaceholder: false } ]; parameterizeSpec(testCases, (spec, name, value) => { - spec(`group row renders expected value when ${name}`, async () => { - await table.setData(value.data); - await connect(); - await waitForUpdatesAsync(); - - expect(pageObject.getRenderedGroupHeaderContent(0)).toBe( - value.groupValue - ); - }); + spec( + `cell and group row render expected value when ${name} and placeholder is configured`, + async () => { + const placeholder = 'Custom placeholder'; + elementReferences.column1.placeholder = placeholder; + await table.setData(value.data); + await connect(); + await waitForUpdatesAsync(); + + const expectedCellText = value.usesColumnPlaceholder + ? placeholder + : value.cellValue; + expect(pageObject.getRenderedCellContent(0, 0)).toBe( + expectedCellText + ); + expect(pageObject.getRenderedGroupHeaderContent(0)).toBe( + value.groupValue + ); + } + ); + }); + + parameterizeSpec(testCases, (spec, name, value) => { + spec( + `cell and group row render expected value when ${name} and placeholder is not configured`, + async () => { + await table.setData(value.data); + await connect(); + await waitForUpdatesAsync(); + + expect(pageObject.getRenderedCellContent(0, 0)).toBe( + value.cellValue + ); + expect(pageObject.getRenderedGroupHeaderContent(0)).toBe( + value.groupValue + ); + } + ); + }); + + it('setting placeholder to undefined updates cells from displaying placeholder to displaying blank', async () => { + const placeholder = 'My placeholder'; + elementReferences.column1.placeholder = placeholder; + await table.setData([{}]); + await connect(); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toBe(placeholder); + + elementReferences.column1.placeholder = undefined; + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toBe(''); + }); + + it('setting placeholder to defined string updates cells from displaying placeholder to displaying blank', async () => { + await table.setData([{}]); + await connect(); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toBe(''); + + const placeholder = 'placeholder'; + elementReferences.column1.placeholder = placeholder; + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toBe(placeholder); + }); + + it('updating placeholder from one string to another updates cell', async () => { + const placeholder1 = 'My first placeholder'; + elementReferences.column1.placeholder = placeholder1; + await table.setData([{}]); + await connect(); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toBe(placeholder1); + + const placeholder2 = 'My second placeholder'; + elementReferences.column1.placeholder = placeholder2; + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toBe(placeholder2); }); }); }); diff --git a/packages/nimble-components/src/table-column/duration-text/tests/table-column-duration-text.stories.ts b/packages/nimble-components/src/table-column/duration-text/tests/table-column-duration-text.stories.ts index 86ea0921cf..d188ae109d 100644 --- a/packages/nimble-components/src/table-column/duration-text/tests/table-column-duration-text.stories.ts +++ b/packages/nimble-components/src/table-column/duration-text/tests/table-column-duration-text.stories.ts @@ -36,7 +36,7 @@ const simpleData = [ { firstName: 'Montgomery', lastName: 'Burns', - swearWordCadence: 3.78e12 + swearWordCadence: undefined } ] as const; @@ -66,6 +66,7 @@ export default metadata; interface TextColumnTableArgs extends SharedTableArgs { fieldName: string; + placeholder: string; } export const durationTextColumn: StoryObj = { @@ -83,6 +84,7 @@ export const durationTextColumn: StoryObj = { <${tableColumnDurationTextTag} field-name="swearWordCadence" + placeholder="${x => x.placeholder}" > Time since last swear word @@ -97,6 +99,7 @@ export const durationTextColumn: StoryObj = { } }, args: { - fieldName: 'firstName' + fieldName: 'firstName', + placeholder: 'Unknown value' } }; diff --git a/packages/nimble-components/src/table-column/mixins/placeholder.ts b/packages/nimble-components/src/table-column/mixins/placeholder.ts new file mode 100644 index 0000000000..f5afca2d79 --- /dev/null +++ b/packages/nimble-components/src/table-column/mixins/placeholder.ts @@ -0,0 +1,32 @@ +import { attr } from '@microsoft/fast-element'; +import type { TableColumn } from '../base'; + +// Pick just the relevant properties the mixin depends on (typescript complains if the mixin declares private / protected base exports) +// Because the 'placeholder' mixin doesn't depend on any properties of the TableColumn, there are no properties to pick. +type TableColumnWithPlaceholder = Pick; +// prettier-ignore +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TableColumnWithPlaceholderConstructor = abstract new (...args: any[]) => TableColumnWithPlaceholder; + +// As the returned class is internal to the function, we can't write a signature that uses is directly, so rely on inference +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type +export function mixinColumnWithPlaceholderAPI< + TBase extends TableColumnWithPlaceholderConstructor +>(base: TBase) { + /** + * The Mixin that provides a concrete column with the API to allow grouping + * by the values in that column. + */ + abstract class ColumnWithPlaceholder extends base { + public placeholder?: string; + + public abstract placeholderChanged(): void; + } + attr({ attribute: 'placeholder' })( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + ColumnWithPlaceholder.prototype, + 'placeholder' + ); + + return ColumnWithPlaceholder; +} \ No newline at end of file diff --git a/packages/nimble-components/src/table-column/number-text/cell-view/index.ts b/packages/nimble-components/src/table-column/number-text/cell-view/index.ts index 0866f88414..d8b879c478 100644 --- a/packages/nimble-components/src/table-column/number-text/cell-view/index.ts +++ b/packages/nimble-components/src/table-column/number-text/cell-view/index.ts @@ -31,7 +31,16 @@ TableColumnNumberTextColumnConfig } private updateText(): void { - this.text = this.columnConfig?.formatter?.format(this.cellRecord?.value) ?? ''; + const cellValue = this.cellRecord?.value; + if ( + this.applyPlaceholderTextIfNeeded( + cellValue, + this.columnConfig?.placeholder + ) + ) { + return; + } + this.text = this.columnConfig?.formatter?.format(cellValue) ?? ''; } } diff --git a/packages/nimble-components/src/table-column/number-text/index.ts b/packages/nimble-components/src/table-column/number-text/index.ts index 2b0ea4a246..51b312707a 100644 --- a/packages/nimble-components/src/table-column/number-text/index.ts +++ b/packages/nimble-components/src/table-column/number-text/index.ts @@ -31,6 +31,7 @@ export type TableColumnNumberTextCellRecord = TableNumberField<'value'>; export interface TableColumnNumberTextColumnConfig { formatter: UnitFormat; alignment: TextCellViewBaseAlignment; + placeholder?: string; } declare global { @@ -97,6 +98,10 @@ export class TableColumnNumberText extends TableColumnTextBase { return this.validator.getValidity(); } + public placeholderChanged(): void { + this.updateColumnConfig(); + } + protected override getColumnInternalsOptions(): ColumnInternalsOptions { return { cellRecordFieldNames: ['value'], @@ -171,7 +176,8 @@ export class TableColumnNumberText extends TableColumnTextBase { if (this.validator.isValid()) { const columnConfig: TableColumnNumberTextColumnConfig = { formatter: this.createFormatter(), - alignment: this.determineCellContentAlignment() + alignment: this.determineCellContentAlignment(), + placeholder: this.placeholder }; this.columnInternals.columnConfig = columnConfig; } else { diff --git a/packages/nimble-components/src/table-column/number-text/tests/table-column-number-text.spec.ts b/packages/nimble-components/src/table-column/number-text/tests/table-column-number-text.spec.ts index cc1163995f..78f406b191 100644 --- a/packages/nimble-components/src/table-column/number-text/tests/table-column-number-text.spec.ts +++ b/packages/nimble-components/src/table-column/number-text/tests/table-column-number-text.spec.ts @@ -78,25 +78,6 @@ describe('TableColumnNumberText', () => { expect(elementReferences.column2.checkValidity()).toBeTrue(); }); - const noValueData = [ - { name: 'field not present', data: [{ unused: 'foo' }] }, - { name: 'value is null', data: [{ number1: null }] }, - { name: 'value is undefined', data: [{ number1: undefined }] }, - { - name: 'value is not a number', - data: [{ number1: 'hello world' as unknown as number }] - } - ] as const; - parameterizeSpec(noValueData, (spec, name, value) => { - spec(`displays empty string when ${name}`, async () => { - await table.setData(value.data); - await connect(); - await waitForUpdatesAsync(); - - expect(pageObject.getRenderedCellTextContent(0, 0)).toBe(''); - }); - }); - it('defaults to "default" format', () => { expect(elementReferences.column1.format).toBe(NumberTextFormat.default); }); @@ -739,50 +720,140 @@ describe('TableColumnNumberText', () => { { name: 'value is not specified', data: [{}], - groupValue: 'No value' + cellValue: '', + groupValue: 'No value', + usesColumnPlaceholder: true }, { name: 'value is undefined', data: [{ number1: undefined }], - groupValue: 'No value' + cellValue: '', + groupValue: 'No value', + usesColumnPlaceholder: true }, { name: 'value is null', data: [{ number1: null }], - groupValue: 'No value' + cellValue: '', + groupValue: 'No value', + usesColumnPlaceholder: true }, { name: 'value is Number.NaN', data: [{ number1: Number.NaN }], - groupValue: 'NaN' + cellValue: 'NaN', + groupValue: 'NaN', + usesColumnPlaceholder: false }, { name: 'value is valid and non-zero', data: [{ number1: 100 }], - groupValue: '100' + cellValue: '100', + groupValue: '100', + usesColumnPlaceholder: false }, { name: 'value is incorrect type', data: [{ number1: 'not a number' as unknown as number }], - groupValue: '' + cellValue: '', + groupValue: '', + usesColumnPlaceholder: false }, { name: 'value is specified and falsey', data: [{ number1: 0 }], - groupValue: '0' + cellValue: '0', + groupValue: '0', + usesColumnPlaceholder: false } ]; parameterizeSpec(testCases, (spec, name, value) => { - spec(`group row renders expected value when ${name}`, async () => { - await table.setData(value.data); - await connect(); - await waitForUpdatesAsync(); + spec( + `cell and group row render expected value when ${name} and placeholder is configured`, + async () => { + const placeholder = 'Custom placeholder'; + elementReferences.column1.placeholder = placeholder; + await table.setData(value.data); + await connect(); + await waitForUpdatesAsync(); + + const expectedCellText = value.usesColumnPlaceholder + ? placeholder + : value.cellValue; + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe( + expectedCellText + ); + expect( + pageObject.getRenderedGroupHeaderTextContent(0) + ).toBe(value.groupValue); + } + ); + }); - expect(pageObject.getRenderedGroupHeaderTextContent(0)).toBe( - value.groupValue - ); - }); + parameterizeSpec(testCases, (spec, name, value) => { + spec( + `cell and group row render expected value when ${name} and placeholder is not configured`, + async () => { + await table.setData(value.data); + await connect(); + await waitForUpdatesAsync(); + + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe( + value.cellValue + ); + expect( + pageObject.getRenderedGroupHeaderTextContent(0) + ).toBe(value.groupValue); + } + ); + }); + + it('setting placeholder to undefined updates cells from displaying placeholder to displaying blank', async () => { + const placeholder = 'My placeholder'; + elementReferences.column1.placeholder = placeholder; + await table.setData([{}]); + await connect(); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe( + placeholder + ); + + elementReferences.column1.placeholder = undefined; + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe(''); + }); + + it('setting placeholder to defined string updates cells from displaying placeholder to displaying blank', async () => { + await table.setData([{}]); + await connect(); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe(''); + + const placeholder = 'placeholder'; + elementReferences.column1.placeholder = placeholder; + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe( + placeholder + ); + }); + + it('updating placeholder from one string to another updates cell', async () => { + const placeholder1 = 'My first placeholder'; + elementReferences.column1.placeholder = placeholder1; + await table.setData([{}]); + await connect(); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe( + placeholder1 + ); + + const placeholder2 = 'My second placeholder'; + elementReferences.column1.placeholder = placeholder2; + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellTextContent(0, 0)).toBe( + placeholder2 + ); }); }); }); diff --git a/packages/nimble-components/src/table-column/number-text/tests/table-column-number-text.stories.ts b/packages/nimble-components/src/table-column/number-text/tests/table-column-number-text.stories.ts index 431a9393ce..44811118de 100644 --- a/packages/nimble-components/src/table-column/number-text/tests/table-column-number-text.stories.ts +++ b/packages/nimble-components/src/table-column/number-text/tests/table-column-number-text.stories.ts @@ -49,7 +49,7 @@ const simpleData = [ lastName: 'Van Houten', age: 14.1, favoriteNumber: -0.00000064532623, - measurement: -0.00000064532623 + measurement: undefined } ] as const; @@ -84,6 +84,7 @@ interface NumberTextColumnTableArgs extends SharedTableArgs { decimalDigits: number; decimalMaximumDigits: number; unit: string; + placeholder: string; checkValidity: () => void; validity: () => void; } @@ -161,10 +162,10 @@ export const numberTextColumn: StoryObj = { <${tableColumnNumberTextTag} field-name="age" format="${x => NumberTextFormat[x.format]}" alignment="${x => NumberTextAlignment[x.alignment]}" decimal-digits="${x => x.decimalDigits}" decimal-maximum-digits="${x => x.decimalMaximumDigits}"> Age - <${tableColumnNumberTextTag} field-name="favoriteNumber" format="${x => NumberTextFormat[x.format]}" alignment="${x => NumberTextAlignment[x.alignment]}" decimal-digits="${x => x.decimalDigits}" decimal-maximum-digits="${x => x.decimalMaximumDigits}"> + <${tableColumnNumberTextTag} field-name="favoriteNumber" format="${x => NumberTextFormat[x.format]}" alignment="${x => NumberTextAlignment[x.alignment]}" decimal-digits="${x => x.decimalDigits}" decimal-maximum-digits="${x => x.decimalMaximumDigits}" placeholder="${x => x.placeholder}"> Favorite Number - <${tableColumnNumberTextTag} field-name="measurement" format="${x => NumberTextFormat[x.format]}" alignment="${x => NumberTextAlignment[x.alignment]}" decimal-digits="${x => x.decimalDigits}" decimal-maximum-digits="${x => x.decimalMaximumDigits}"> + <${tableColumnNumberTextTag} field-name="measurement" format="${x => NumberTextFormat[x.format]}" alignment="${x => NumberTextAlignment[x.alignment]}" decimal-digits="${x => x.decimalDigits}" decimal-maximum-digits="${x => x.decimalMaximumDigits}" placeholder="${x => x.placeholder}"> Measurement ${when(x => x.unit === 'byte', html`<${unitByteTag}>`)} ${when(x => x.unit === 'byte (1024)', html`<${unitByteTag} binary>`)} @@ -223,6 +224,7 @@ export const numberTextColumn: StoryObj = { alignment: 'default', decimalDigits: 2, decimalMaximumDigits: undefined, - unit: 'volt' + unit: 'volt', + placeholder: 'Unknown value' } }; diff --git a/packages/nimble-components/src/table-column/text-base/cell-view/index.ts b/packages/nimble-components/src/table-column/text-base/cell-view/index.ts index 870f701b25..b56f6e6188 100644 --- a/packages/nimble-components/src/table-column/text-base/cell-view/index.ts +++ b/packages/nimble-components/src/table-column/text-base/cell-view/index.ts @@ -2,6 +2,7 @@ import { observable } from '@microsoft/fast-element'; import { TableCellView } from '../../base/cell-view'; import type { TableCellRecord } from '../../base/types'; import { TextCellViewBaseAlignment } from './types'; +import type { TableFieldValue } from '../../../table/types'; /** * The cell view base class for displaying fields of any type as text. @@ -20,9 +21,29 @@ export abstract class TableColumnTextCellViewBase< @observable public text = ''; + /** + * Whether or not the text being displayed in the cell view is a placeholder. + */ + @observable + public isPlaceholder = false; + /** * The alignment of the text within the cell. */ @observable public alignment: TextCellViewBaseAlignment = TextCellViewBaseAlignment.left; + + protected applyPlaceholderTextIfNeeded( + cellValue: TableFieldValue, + placeholder: string | undefined + ): boolean { + if (placeholder && (cellValue === null || cellValue === undefined)) { + this.text = placeholder; + this.isPlaceholder = true; + } else { + this.isPlaceholder = false; + } + + return this.isPlaceholder; + } } diff --git a/packages/nimble-components/src/table-column/text-base/cell-view/styles.ts b/packages/nimble-components/src/table-column/text-base/cell-view/styles.ts index db6a7a94ca..af765c9141 100644 --- a/packages/nimble-components/src/table-column/text-base/cell-view/styles.ts +++ b/packages/nimble-components/src/table-column/text-base/cell-view/styles.ts @@ -1,5 +1,10 @@ import { css } from '@microsoft/fast-element'; -import { bodyFont, bodyFontColor } from '../../../theme-provider/design-tokens'; +import { + bodyFont, + bodyFontColor, + placeholderFont, + placeholderFontColor +} from '../../../theme-provider/design-tokens'; export const styles = css` :host(.right-align) { @@ -13,4 +18,9 @@ export const styles = css` overflow: hidden; text-overflow: ellipsis; } + + :host(.placeholder) span { + font: ${placeholderFont}; + color: ${placeholderFontColor}; + } `; diff --git a/packages/nimble-components/src/table-column/text-base/cell-view/template.ts b/packages/nimble-components/src/table-column/text-base/cell-view/template.ts index 3ace67962e..bb68d303e6 100644 --- a/packages/nimble-components/src/table-column/text-base/cell-view/template.ts +++ b/packages/nimble-components/src/table-column/text-base/cell-view/template.ts @@ -4,11 +4,13 @@ import type { TableColumnTextCellViewBase } from '.'; import { overflow } from '../../../utilities/directive/overflow'; import { TextCellViewBaseAlignment } from './types'; +// prettier-ignore export const template = html`