From f6cbae7c1e563bcf18d15d53c1cfb02ebadc6cd3 Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Wed, 12 Aug 2026 09:41:08 -0700 Subject: [PATCH 1/3] Add Date & Time, Date, and Time column types that render epoch/ISO timestamps in the viewer's locale and timezone with docs and specs --- docs/en/nodes/widgets/ui-table.md | 5 + docs/examples/ui-table-cell-types.json | 9 +- nodes/widgets/ui_table.html | 3 + test/helpers/date.helper.spec.js | 146 ++++++++++++++++++ ui/src/widgets/ui-table/UITableCell.vue | 8 + .../widgets/ui-table/helpers/date.helper.js | 57 +++++++ 6 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 test/helpers/date.helper.spec.js create mode 100644 ui/src/widgets/ui-table/helpers/date.helper.js 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 @@ +