diff --git a/cypress/fixtures/flows/dashboard-tables.json b/cypress/fixtures/flows/dashboard-tables.json index 3996557ac..1bd63d103 100644 --- a/cypress/fixtures/flows/dashboard-tables.json +++ b/cypress/fixtures/flows/dashboard-tables.json @@ -317,5 +317,85 @@ "groupBg": "#ffffff", "groupOutline": "#cccccc" } + }, + { + "id": "dashboard-ui-table-dates-inject", + "type": "inject", + "z": "node-red-tab-tables", + "name": "date rows", + "props": [ + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "", + "payload": "[{\"dt\":1749988800000,\"d\":1749988800000,\"t\":1749988800000,\"raw\":1749988800000}]", + "payloadType": "json", + "x": 300, + "y": 560, + "wires": [ + [ + "dashboard-ui-table-dates" + ] + ] + }, + { + "id": "dashboard-ui-table-dates", + "type": "ui-table", + "z": "node-red-tab-tables", + "group": "dashboard-ui-group", + "name": "Dates", + "label": "", + "action": "replace", + "order": 0, + "width": 0, + "height": 0, + "maxrows": 0, + "passthru": false, + "autocols": false, + "selectionType": "none", + "columns": [ + { + "title": "Datetime", + "key": "dt", + "keyType": "key", + "type": "datetime", + "width": "", + "align": "start" + }, + { + "title": "Date", + "key": "d", + "keyType": "key", + "type": "date", + "width": "", + "align": "start" + }, + { + "title": "Time", + "key": "t", + "keyType": "key", + "type": "time", + "width": "", + "align": "start" + }, + { + "title": "Raw", + "key": "raw", + "keyType": "key", + "type": "text", + "width": "", + "align": "start" + } + ], + "x": 520, + "y": 560, + "wires": [ + [] + ] } ] \ No newline at end of file diff --git a/cypress/tests/widgets/table.spec.js b/cypress/tests/widgets/table.spec.js index 8996c4f19..95d4dd68d 100644 --- a/cypress/tests/widgets/table.spec.js +++ b/cypress/tests/widgets/table.spec.js @@ -64,4 +64,18 @@ describe('Node-RED Dashboard 2.0 - Tables', () => { cy.get('#nrdb-ui-widget-dashboard-ui-table-table-buttons-string-value').find('button').eq(3).should('have.text', fixedString) cy.get('#nrdb-ui-widget-dashboard-ui-table-table-buttons-string-value').find('button').eq(4).should('have.text', fixedString) }) + + it('formats datetime/date/time columns instead of showing the raw epoch', () => { + // the row's timestamp is 2025-06-15T12:00:00Z — midday keeps the year 2025 in every timezone + const epoch = '1749988800000' + const cells = () => cy.get('#nrdb-ui-widget-dashboard-ui-table-dates').find('tbody tr').eq(0).find('td') + cy.get('#nrdb-ui-widget-dashboard-ui-table-dates').find('tbody tr').should('have.length', 1) + // datetime + date columns render a human date, not the epoch number + cells().eq(0).should('contain.text', '2025').and('not.contain.text', epoch) + cells().eq(1).should('contain.text', '2025').and('not.contain.text', epoch) + // time column renders a clock value + cells().eq(2).invoke('text').should('match', /\d{1,2}:\d{2}/) + // a plain text column still shows the raw value, confirming only date-typed columns format + cells().eq(3).should('contain.text', epoch) + }) }) diff --git a/docs/en/nodes/widgets/ui-table.md b/docs/en/nodes/widgets/ui-table.md index 1b2090129..d91f62d78 100644 --- a/docs/en/nodes/widgets/ui-table.md +++ b/docs/en/nodes/widgets/ui-table.md @@ -141,6 +141,9 @@ _An example of a ui-table displaying various of the cell types available_ - **Button**: Renders a clickable button in the cell. The label of the button will be either the `row[key]` or the fixed string entered on the manual column configuration. - **Row Number**: Renders the row number into the cell. - **Image**: Renders the cell as an image. The "Image" value provided should be a valid URL. A data url is also supported for base64 encoded images. When an invalid url is specified, an empty space will appear. +- **Date & Time**: Renders a timestamp as a locale date and time. The value can be an epoch (seconds or milliseconds), an ISO-8601 string, or a `Date`. Formatting uses the viewer's own browser locale and timezone, so a UTC timestamp displays in local time. +- **Date**: As **Date & Time**, but shows the date only. +- **Time**: As **Date & Time**, but shows the time only. #### Interaction: Buttons @@ -170,6 +173,8 @@ Given the `action` and `column` keys, you can determine which button was clicked The `ui-table` node can be configured to include a search bar above the table. This will allow users to search and filter across all columns, and automatically search across all columns when you type. +Search matches the underlying cell value, not the rendered output. Columns that render their value differently — `Date & Time`, `Date`, `Time`, `Color`, `Progress`, and the sparklines — are therefore searched by their raw data (e.g. the epoch number), not the displayed text. + #### Example ![Example of a Data Table with Search & Filter](/images/node-examples/ui-table-search.png "Example of a Data Table with Search & Filter"){data-zoomable} diff --git a/docs/examples/ui-table-cell-types.json b/docs/examples/ui-table-cell-types.json index 2d84a4745..4478f7cfa 100644 --- a/docs/examples/ui-table-cell-types.json +++ b/docs/examples/ui-table-cell-types.json @@ -76,6 +76,13 @@ "type": "color", "width": "", "align": "center" + }, + { + "title": "Date & Time", + "key": "timestamp", + "type": "datetime", + "width": "", + "align": "start" } ], "x": 490, @@ -99,7 +106,7 @@ "once": true, "onceDelay": 0.1, "topic": "", - "payload": "[{\"columnA\":\"Hello\",\"columnB\":\"World\",\"link\":\"https://news.bbc.co.uk\",\"progress\":76,\"color\":\"#FF0000\",\"array\":[6,7,9,2,4],\"boolean\":true},{\"columnA\":\"Hey\",\"columnB\":\"Globe\",\"link\":\"https://flowfuse.com\",\"progress\":56,\"color\":\"#24aa69\",\"array\":[1,3,6,3,5],\"boolean\":false}]", + "payload": "[{\"columnA\":\"Hello\",\"columnB\":\"World\",\"link\":\"https://news.bbc.co.uk\",\"progress\":76,\"color\":\"#FF0000\",\"array\":[6,7,9,2,4],\"boolean\":true,\"timestamp\":1710484200000},{\"columnA\":\"Hey\",\"columnB\":\"Globe\",\"link\":\"https://flowfuse.com\",\"progress\":56,\"color\":\"#24aa69\",\"array\":[1,3,6,3,5],\"boolean\":false,\"timestamp\":1735689540000}]", "payloadType": "json", "x": 350, "y": 60, diff --git a/nodes/widgets/ui_table.html b/nodes/widgets/ui_table.html index 6a017bf20..96693af61 100644 --- a/nodes/widgets/ui_table.html +++ b/nodes/widgets/ui_table.html @@ -48,6 +48,9 @@ (function () { const COLUMN_TYPES = [ { value: 'text', label: 'Text' }, + { value: 'datetime', label: 'Date & Time' }, + { value: 'date', label: 'Date' }, + { value: 'time', label: 'Time' }, { value: 'html', label: 'HTML' }, { value: 'link', label: 'Link (https://...)' }, { value: 'color', label: 'Color (#RRGGBB)' }, diff --git a/test/helpers/date.helper.spec.js b/test/helpers/date.helper.spec.js new file mode 100644 index 000000000..0b392140d --- /dev/null +++ b/test/helpers/date.helper.spec.js @@ -0,0 +1,146 @@ +const should = require('should') // eslint-disable-line no-unused-vars + +const dateHelper = require('../../ui/src/widgets/ui-table/helpers/date.helper.js') + +describe('date.helper', function () { + describe('toDate', function () { + it('treats a 13-digit number as epoch milliseconds', function () { + dateHelper.toDate(1723377600000).getTime().should.equal(1723377600000) + }) + + it('treats a 10-digit number as epoch seconds (scales to ms)', function () { + dateHelper.toDate(1723377600).getTime().should.equal(1723377600000) + }) + + it('treats a pre-2001 ms epoch as milliseconds, not seconds', function () { + dateHelper.toDate(946684800000).getTime().should.equal(946684800000) + }) + + it('treats the threshold value (1e11) as milliseconds', function () { + dateHelper.toDate(dateHelper.SECONDS_TO_MS_THRESHOLD).getTime() + .should.equal(dateHelper.SECONDS_TO_MS_THRESHOLD) + }) + + it('treats just below the threshold as seconds', function () { + const belowThreshold = dateHelper.SECONDS_TO_MS_THRESHOLD - 1 + dateHelper.toDate(belowThreshold).getTime().should.equal(belowThreshold * 1000) + }) + + it('passes a Date instance through unchanged', function () { + const d = new Date('2024-08-11T12:00:00Z') + dateHelper.toDate(d).should.equal(d) + }) + + it('parses an ISO-8601 string', function () { + dateHelper.toDate('2024-08-11T12:00:00Z').getTime() + .should.equal(Date.parse('2024-08-11T12:00:00Z')) + }) + + it('returns an Invalid Date for an unparseable string', function () { + isNaN(dateHelper.toDate('not a date').getTime()).should.equal(true) + }) + + it('coerces a 13-digit epoch STRING to the same instant as the number', function () { + dateHelper.toDate('1723377600000').getTime().should.equal(1723377600000) + }) + + it('coerces a 10-digit epoch STRING (seconds) and scales to ms', function () { + dateHelper.toDate('1723377600').getTime().should.equal(1723377600000) + }) + + it('does NOT coerce a short numeric string like a year — parses it as a date', function () { + dateHelper.toDate('2024').getUTCFullYear().should.equal(2024) + }) + }) + + describe('formatCellDate', function () { + const opts = { locale: 'en-GB', timeZone: 'UTC' } + + it('returns "" for null, undefined and empty string', function () { + dateHelper.formatCellDate(null).should.equal('') + dateHelper.formatCellDate(undefined).should.equal('') + dateHelper.formatCellDate('').should.equal('') + }) + + it('returns the raw value (stringified) when it is not a parseable date', function () { + dateHelper.formatCellDate('hello').should.equal('hello') + }) + + it('formats ms, seconds and ISO forms of the same instant identically', function () { + const fromMs = dateHelper.formatCellDate(1723377600000, opts) + const fromSeconds = dateHelper.formatCellDate(1723377600, opts) + const fromIso = dateHelper.formatCellDate('2024-08-11T12:00:00Z', opts) + fromMs.should.equal(fromSeconds) + fromMs.should.equal(fromIso) + }) + + it('formats to the given locale/timezone', function () { + const formatted = dateHelper.formatCellDate(1723377600000, opts) + formatted.should.containEql('2024') + formatted.should.containEql('11') + formatted.should.containEql('08') + }) + + it('formats string and numeric epoch forms identically', function () { + dateHelper.formatCellDate('1723377600000', opts) + .should.equal(dateHelper.formatCellDate(1723377600000, opts)) + dateHelper.formatCellDate('1723377600', opts) + .should.equal(dateHelper.formatCellDate(1723377600, opts)) + }) + + it('passes booleans through as-is rather than rendering a 1970 date', function () { + dateHelper.formatCellDate(false).should.equal('false') + dateHelper.formatCellDate(true).should.equal('true') + }) + + it('passes objects/arrays through as-is', function () { + dateHelper.formatCellDate({ a: 1 }).should.equal('[object Object]') + }) + + it('returns "" for an already-Invalid Date instance (not "Invalid Date")', function () { + dateHelper.formatCellDate(new Date('nope')).should.equal('') + }) + + it('does not mis-format a year-like string as 1970', function () { + dateHelper.formatCellDate('2024', opts).should.containEql('2024') + }) + + it('does not throw on an invalid timeZone — falls back to a non-empty string', function () { + const formatted = dateHelper.formatCellDate(1723377600000, { timeZone: 'Not/AZone' }) + formatted.should.be.a.String() + formatted.length.should.be.above(0) + }) + + describe('format option', function () { + const ms = 1723377600000 + + it('format:"date" renders date only (matches toLocaleDateString)', function () { + dateHelper.formatCellDate(ms, { format: 'date', ...opts }) + .should.equal(new Date(ms).toLocaleDateString('en-GB', { timeZone: 'UTC' })) + }) + + it('format:"time" renders time only (matches toLocaleTimeString)', function () { + dateHelper.formatCellDate(ms, { format: 'time', ...opts }) + .should.equal(new Date(ms).toLocaleTimeString('en-GB', { timeZone: 'UTC' })) + }) + + it('format:"datetime" (default) differs from date-only and time-only', function () { + const both = dateHelper.formatCellDate(ms, { format: 'datetime', ...opts }) + both.should.not.equal(dateHelper.formatCellDate(ms, { format: 'date', ...opts })) + both.should.not.equal(dateHelper.formatCellDate(ms, { format: 'time', ...opts })) + }) + + it('an unknown/absent format falls back to date & time', function () { + const datetime = dateHelper.formatCellDate(ms, { format: 'datetime', ...opts }) + dateHelper.formatCellDate(ms, { format: 'nonsense', ...opts }).should.equal(datetime) + dateHelper.formatCellDate(ms, opts).should.equal(datetime) + }) + + it('honours the format even when the timeZone is invalid (no throw)', function () { + const formatted = dateHelper.formatCellDate(ms, { format: 'time', timeZone: 'Not/AZone' }) + formatted.should.be.a.String() + formatted.length.should.be.above(0) + }) + }) + }) +}) diff --git a/ui/src/widgets/ui-table/UITableCell.vue b/ui/src/widgets/ui-table/UITableCell.vue index 74e3932b7..7d4b459b4 100644 --- a/ui/src/widgets/ui-table/UITableCell.vue +++ b/ui/src/widgets/ui-table/UITableCell.vue @@ -32,12 +32,17 @@ +