Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions cypress/fixtures/flows/dashboard-tables.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
[]
]
}
]
14 changes: 14 additions & 0 deletions cypress/tests/widgets/table.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
5 changes: 5 additions & 0 deletions docs/en/nodes/widgets/ui-table.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}
Expand Down
9 changes: 8 additions & 1 deletion docs/examples/ui-table-cell-types.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@
"type": "color",
"width": "",
"align": "center"
},
{
"title": "Date & Time",
"key": "timestamp",
"type": "datetime",
"width": "",
"align": "start"
}
],
"x": 490,
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions nodes/widgets/ui_table.html
Original file line number Diff line number Diff line change
Expand Up @@ -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)' },
Expand Down
146 changes: 146 additions & 0 deletions test/helpers/date.helper.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
})
8 changes: 8 additions & 0 deletions ui/src/widgets/ui-table/UITableCell.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,17 @@
<template v-else-if="type === 'image'">
<v-img :src="value" />
</template>
<template v-else-if="type === 'datetime' || type === 'date' || type === 'time'">
{{ formattedDate }}
</template>
<template v-else>
{{ value }}
</template>
</template>

<script>
import dateHelper from './helpers/date.helper'

export default {
name: 'UITableCell',
props: {
Expand Down Expand Up @@ -80,6 +85,9 @@
set (value) {
this.$emit('update:modelValue', value)
}
},
formattedDate () {
return dateHelper.formatCellDate(this.value, { format: this.type })

Check warning on line 90 in ui/src/widgets/ui-table/UITableCell.vue

View workflow job for this annotation

GitHub Actions / build / Build on 24

Caution: `dateHelper` also has a named export `formatCellDate`. Check if you meant to write `import {formatCellDate} from './helpers/date.helper'` instead
}
},
methods: {
Expand Down
57 changes: 57 additions & 0 deletions ui/src/widgets/ui-table/helpers/date.helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const SECONDS_TO_MS_THRESHOLD = 1e11
const EPOCH_STRING = /^-?\d{10,}$/

const FORMATTERS = {
datetime: 'toLocaleString',
date: 'toLocaleDateString',
time: 'toLocaleTimeString'
}

function toDate (value) {
if (value instanceof Date) {
return value
}
if (typeof value === 'string' && EPOCH_STRING.test(value.trim())) {
return toDate(Number(value.trim()))
}
if (typeof value === 'number') {
return new Date(value < SECONDS_TO_MS_THRESHOLD ? value * 1000 : value)
}
return new Date(value)
}

function formatCellDate (value, { locale, timeZone, format } = {}) {
if (value === null || typeof value === 'undefined' || value === '') {
return ''
}
if (typeof value !== 'number' && typeof value !== 'string' && !(value instanceof Date)) {
return String(value)
}
const date = toDate(value)
if (isNaN(date.getTime())) {
return typeof value === 'string' ? value : ''
}
const method = FORMATTERS[format] || FORMATTERS.datetime
try {
return date[method](locale, timeZone ? { timeZone } : undefined)
} catch (e) {
return date[method]()
}
}

export default {
formatCellDate,
toDate,
SECONDS_TO_MS_THRESHOLD
}

export { formatCellDate, toDate, SECONDS_TO_MS_THRESHOLD }

if (typeof module !== 'undefined' && module.exports) {
module.exports = {
formatCellDate,
toDate,
SECONDS_TO_MS_THRESHOLD
}
module.exports.default = module.exports
}
Loading