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
{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 @@
+
+ {{ formattedDate }}
+
{{ value }}