Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/unix-timestamp-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@json-edit-react/components': minor
---

Add a `UnixTimestamp` component: epoch numbers (seconds or milliseconds) rendered as a readable date, reusing the same swappable `DatePicker` widget for editing.

The guard matches numbers in a plausible epoch window (years 1990–2100, as seconds or ms) — a heuristic, so target real timestamp fields with a `condition` override (ANDed with the guard) to avoid catching unrelated numbers. The unit defaults to `'auto'` (detected from magnitude, since the seconds and millisecond ranges don't overlap) and is preserved on commit; force it with `componentProps.unit`. The read-only view defaults to `displayAs: 'number'` (the ordinary number node plus a badge, default `'UNIX'`, via `badgeLabel`); `displayAs: 'date'` shows a formatted date with an optional `formatter`. Editing uses the picker passed via `componentProps.DatePicker` (e.g. `ReactDatePicker` from `@json-edit-react/components/widgets`); with none, the standard number editor handles edits. The value stays a plain JSON number throughout.
6 changes: 5 additions & 1 deletion demo/src/demoData/data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ const customComponentLibraryData = {

### Components available:
- Hyperlink
- "Enhanced" link
- DatePicker
- DateObject
- UNIX Timestamp
- Undefined
- Markdown
- "Enhanced" link
- BigInt
- BooleanToggle
- NaN
Expand All @@ -37,6 +38,9 @@ const customComponentLibraryData = {
'Date Picker': new Date().toISOString(),
'Date Object': new Date(),
'Show Time in Date?': true,
'Unix Timestamp (seconds)': Math.floor(Date.now() / 1000),
'Unix Timestamp (ms)': Date.now(),
'Show Unix as raw number?': true,
// info: 'Inserted in App.tsx',
},

Expand Down
12 changes: 12 additions & 0 deletions demo/src/demoData/dataDefinitions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
enhancedLinkDefinition,
imageDefinition,
colorPickerDefinition,
unixTimestampDefinition,
} from '@json-edit-react/components'
import { ReactDatePicker } from '@json-edit-react/components/widgets'
import {
Expand Down Expand Up @@ -1131,6 +1132,17 @@ export const demoDataDefinitions: Record<string, DemoData> = {
DatePicker: ReactDatePicker,
},
}),
unixTimestampDefinition({
componentProps: {
DatePicker: ReactDatePicker,
showTime: libraryData?.['Date & Time']?.['Show Time in Date?'] ?? false,
// The `unit` defaults to 'auto', so the seconds and millisecond
// fields are each detected by magnitude.
displayAs: (libraryData?.['Date & Time']?.['Show Unix as raw number?'] ?? true)
? 'number'
: 'date',
},
}),
imageDefinition({
componentProps: {
imageStyles: {
Expand Down
14 changes: 14 additions & 0 deletions packages/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Each component ships a React component plus a definition factory that produces a
| `EnhancedLink` | Object-shaped data with `{text, url}` rendered as a link |
| `DatePicker` | ISO date strings, edited via a swappable date-picker widget — pass `ReactDatePicker` (or your own) via `componentProps.DatePicker` |
| `DateObject` | JavaScript `Date` objects |
| `UnixTimestamp` | Epoch numbers (seconds or milliseconds) shown as a date, edited via the same swappable picker as `DatePicker` |
| `ColorPicker` | Hex/RGB/HSL color strings, edited via `react-colorful` |
| `Markdown` | Markdown-formatted strings, rendered via `react-markdown` |
| `Image` | Image URLs displayed inline |
Expand Down Expand Up @@ -86,6 +87,19 @@ datePickerDefinition({

The read-only display defaults to the locale date/time; pass a `formatter: (date: Date) => string` in `componentProps` to customise it independently of the picker.

### `UnixTimestamp` — epoch numbers as dates

`UnixTimestamp` matches numbers in a plausible epoch window (years 1990–2100, as seconds or milliseconds) and renders them as a date, reusing the same swappable `DatePicker` widget for editing. The unit defaults to `'auto'` (detected from the value's magnitude, since the seconds and millisecond ranges don't overlap) and is preserved on commit; force it with `componentProps.unit` (`'seconds' | 'milliseconds'`).

The match is a heuristic, so targeting real timestamp fields and avoiding unrelated numbers is up to you. There are two override surfaces, and `UnixTimestamp` is the component where the difference matters most:

- **`condition`** narrows — it's ANDed with the guard, so a node matches only if it's *both* targeted *and* a plausible epoch: `unixTimestampDefinition({ condition: byKey(/(^|_)(created|updated)(At|_at)?$/i) })` (see `@json-edit-react/utils`).
- **`guard`** replaces the heuristic entirely, making your targeting the sole criterion: `unixTimestampDefinition({ guard: byKey(/(^|_)(created|updated)(At|_at)?$/i) })`.

Unlike `DatePicker` (whose guard is a safety contract — the date parser would choke on a non-ISO string), `UnixTimestamp`'s guard is *only* a heuristic: any number renders fine as a date, so there's nothing to protect and replacing it is safe. Prefer `guard` when you have many numbers that *look* like epochs but aren't, and want the key alone to decide — it also avoids the band silently rejecting a real timestamp that falls outside 1990–2100 (microsecond/nanosecond epochs, historical or far-future dates), which the ANDed `condition` would.

The read-only view defaults to `displayAs: 'number'` — the ordinary number node with a small badge (default `'UNIX'`, set via `badgeLabel`) marking it as a timestamp. Set `displayAs: 'date'` for a formatted date instead (with the same optional `formatter`). Editing uses the widget when one is passed via `componentProps.DatePicker`; with none, the standard number editor handles edits.

### `ErrorIndicator` — flag nodes with a glyph

Unlike the other components, `ErrorIndicator` has no intrinsic value type: it wraps a value (leaf) node and adds a glyph beside whichever nodes you point it at via `condition`. It pairs naturally with `useValidationState` from `@json-edit-react/utils` to mark invalid nodes.
Expand Down
122 changes: 122 additions & 0 deletions packages/components/src/UnixTimestamp/component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Renders Unix-epoch numbers (seconds or milliseconds) as a readable date.
*
* Editing reuses the same swappable picker as `DatePicker`: pass `ReactDatePicker`
* (or any `DatePickerWidgetProps` component) via `componentProps.DatePicker`.
* The definition enables editing-mode rendering only when a widget is supplied;
* with none, the node's standard number editor handles edits (see
* `definition.ts`).
*
* The read-only view has two modes (`componentProps.displayAs`):
* - `'number'` (default): the standard number node (`originalNode`) plus a
* small badge (default "UNIX") marking it as a timestamp — uses the
* `ErrorIndicator` decorator pattern.
* - `'date'`: a formatted date (locale, or a custom `formatter`).
*/

import React from 'react'
import { type CustomComponentProps } from 'json-edit-react'
import { type DatePickerWidgetProps } from '../_common/DatePickerWidget'
import { epochToDate, dateToEpoch, type UnixTimeUnit } from './epoch'
import './style.css'

export interface UnixTimestampCustomProps {
// The picker rendered while editing. The definition enables edit rendering
// only when this is supplied; otherwise core's standard number editor edits.
DatePicker?: React.ComponentType<DatePickerWidgetProps>
showTime?: boolean
// Whether stored values are epoch seconds or milliseconds. `'auto'` (default)
// detects per value from its magnitude.
unit?: UnixTimeUnit
// Read-only display mode. `'number'` (default) shows the raw number with a
// badge; `'date'` shows a formatted date.
displayAs?: 'date' | 'number'
// Badge text for the `'number'` display mode. Default `'UNIX'`.
badgeLabel?: React.ReactNode
// Customises the `'date'` display. Defaults to locale date/time per showTime.
formatter?: (date: Date) => string
}

export const UnixTimestamp = (props: CustomComponentProps<UnixTimestampCustomProps>) => {
const {
value,
setValue,
handleEdit,
handleCancel,
onKeyDown,
isEditing,
setIsEditing,
getStyles,
nodeData,
canEdit,
originalNode,
componentProps,
} = props

const {
DatePicker,
showTime = true,
unit = 'auto',
displayAs = 'number',
badgeLabel = 'UNIX',
formatter,
} = componentProps ?? {}

const numericValue = typeof value === 'number' ? value : Number(value)

// Editing only reaches this component when a widget is supplied (the
// definition sets `showOnEdit` accordingly); without one, core's number
// editor handles edits and this branch never runs.
if (isEditing && DatePicker) {
const date = epochToDate(numericValue, unit)
return (
<DatePicker
value={isNaN(date.getTime()) ? null : date}
showTime={showTime}
onChange={(newDate) => newDate && setValue(dateToEpoch(newDate, unit, numericValue))}
onConfirm={(newDate) =>
handleEdit(newDate instanceof Date ? dateToEpoch(newDate, unit, numericValue) : undefined)
}
onCancel={handleCancel}
onKeyDown={onKeyDown}
/>
)
}

// View mode — `'number'`: the standard number node plus a UNIX badge.
if (displayAs === 'number')
return (
<span
className="jer-unix-timestamp-wrapper"
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4em' }}
>
{originalNode}
<span className="jer-unix-badge" onDoubleClick={() => canEdit && setIsEditing(true)}>
{badgeLabel}
</span>
</span>
)

// View mode — `'date'`: a formatted date (raw value if it doesn't parse).
const date = epochToDate(numericValue, unit)
const isValidDate = !isNaN(date.getTime())
const displayValue = !isValidDate
? String(value)
: formatter
? formatter(date)
: showTime
? date.toLocaleString()
: date.toLocaleDateString()

return (
<div
// Double-click to edit, like standard value nodes (enters the widget when
// supplied, else core's number editor).
onDoubleClick={() => canEdit && setIsEditing(true)}
className="jer-value-string"
style={getStyles('string', nodeData)}
>
{displayValue}
</div>
)
}
71 changes: 71 additions & 0 deletions packages/components/src/UnixTimestamp/definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* A Unix-timestamp component: epoch numbers (seconds or milliseconds) shown as
* a readable date, edited via a swappable date-picker widget. Database dumps
* and API responses are full of them.
*/

import { type CustomNodeDefinition } from 'json-edit-react'
import {
createDefinitionFactory,
type DefinitionOverrides,
} from '../_common/createDefinitionFactory'
import { UnixTimestamp, type UnixTimestampCustomProps } from './component'
import { isPlausibleEpoch } from './epoch'
// Imported here too (as in DatePicker / ErrorIndicator) so `sideEffects: false`
// can't tree-shake the badge styles out for consumers who only reach the
// factory.
import './style.css'

const UnixTimestampDefinition: CustomNodeDefinition<UnixTimestampCustomProps> = {
// The condition doubles as the guard: a number in the plausible epoch window
// (1990–2100), as seconds or ms. It's a heuristic — consumer `condition`
// overrides are targeting, ANDed with this, so narrow by key (`createdAt`,
// `updatedAt`, …) to avoid matching unrelated numbers. Replacing the guard
// needs the explicit `guard` override.
condition: ({ value }) => isPlausibleEpoch(value),
component: UnixTimestamp,
showOnView: true,
// Defaults: a view decorator. `showOnEdit` and `passOriginalNode` are set per
// the consumer's componentProps by the factory below.
showOnEdit: false,
passOriginalNode: true,
name: 'Unix Timestamp', // shown in the Type selector menu
showInTypeSelector: true,
editOnTypeSwitch: true,
// Seed a new value as the current time. Seconds is the common "unix
// timestamp" meaning; a forced ms unit re-seeds via `fromStandardType`.
defaultValue: Math.floor(Date.now() / 1000),
// Coerce the committed buffer to a number (the standard number editor already
// yields one; a type-switch may hand us anything). Unparseable input seeds
// 'now' in the configured unit.
fromStandardType: (value, _, componentProps) => {
const num = typeof value === 'number' ? value : Number(value)
if (Number.isFinite(num)) return num
const now = Date.now()
return componentProps?.unit === 'milliseconds' ? now : Math.floor(now / 1000)
},
componentProps: { showTime: true },
}

const baseFactory = createDefinitionFactory(UnixTimestampDefinition)

/**
* Build a Unix-timestamp `CustomNodeDefinition`. Pass `ReactDatePicker` (from
* `@json-edit-react/components/widgets`) as `componentProps.DatePicker` for
* calendar editing; without a widget, editing falls back to the standard number
* editor. `componentProps.displayAs` (`'number'` default | `'date'`) chooses the
* read-only view.
*/
export const unixTimestampDefinition = (
overrides: DefinitionOverrides<UnixTimestampCustomProps> = {}
): CustomNodeDefinition<UnixTimestampCustomProps> => {
const definition = baseFactory(overrides)
// A DatePicker widget enables custom editing; without one, defer to core's
// standard number editor (`showOnEdit` stays false).
if (overrides.componentProps?.DatePicker) definition.showOnEdit = true
// `originalNode` is only needed for the `'number'` badge view; skip computing
// it in `'date'` mode.
if ((overrides.componentProps?.displayAs ?? 'number') !== 'number')
definition.passOriginalNode = false
return definition
}
40 changes: 40 additions & 0 deletions packages/components/src/UnixTimestamp/epoch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Epoch heuristics for the Unix-timestamp component.
*
* Plausible window: 1990-01-01 to 2100-01-01. The seconds and millisecond
* bands don't overlap, so a single magnitude check serves two jobs: it's the
* component's guard (keeping it off unrelated numbers like prices or IDs) and
* it tells a seconds-epoch from a millisecond-epoch for the `'auto'` unit.
*/

export type UnixTimeUnit = 'auto' | 'seconds' | 'milliseconds'

const SECONDS_MIN = 631152000 // 1990-01-01T00:00:00Z, in seconds
const SECONDS_MAX = 4102444800 // 2100-01-01T00:00:00Z, in seconds
const MS_MIN = SECONDS_MIN * 1000
const MS_MAX = SECONDS_MAX * 1000

export const isSecondsEpoch = (value: number) => value >= SECONDS_MIN && value <= SECONDS_MAX

export const isMillisecondsEpoch = (value: number) => value >= MS_MIN && value <= MS_MAX

// Doubles as the component's guard — see file header.
export const isPlausibleEpoch = (value: unknown): value is number =>
typeof value === 'number' &&
Number.isFinite(value) &&
(isSecondsEpoch(value) || isMillisecondsEpoch(value))

// Resolve `'auto'` to a concrete unit from the value's magnitude; a forced unit
// wins. Outside both bands (only reachable via a forced or garbage value),
// `'auto'` assumes milliseconds (JS-native).
export const resolveUnit = (value: number, unit: UnixTimeUnit): 'seconds' | 'milliseconds' =>
unit === 'auto' ? (isSecondsEpoch(value) ? 'seconds' : 'milliseconds') : unit

export const epochToDate = (value: number, unit: UnixTimeUnit): Date =>
new Date(resolveUnit(value, unit) === 'seconds' ? value * 1000 : value)

// Serialise a picked Date back to an epoch number, preserving the unit. The
// `reference` value (the one being edited) resolves the `'auto'` unit, so a
// commit keeps the field in whatever unit it started in.
export const dateToEpoch = (date: Date, unit: UnixTimeUnit, reference: number): number =>
resolveUnit(reference, unit) === 'seconds' ? Math.round(date.getTime() / 1000) : date.getTime()
2 changes: 2 additions & 0 deletions packages/components/src/UnixTimestamp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './definition'
export * from './component'
14 changes: 14 additions & 0 deletions packages/components/src/UnixTimestamp/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/* Small "UNIX" badge shown after the number in the `displayAs: 'number'` view,
distinguishing a timestamp from an ordinary number. */
.jer-unix-badge {
font-size: 0.7em;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 0.3em 0.45em;
border-radius: 0.4em;
background: rgba(222, 219, 28, 0.286);
opacity: 0.75;
vertical-align: middle;
user-select: none;
}
1 change: 1 addition & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './EnhancedLink'
export * from './DateObject'
export * from './Undefined'
export * from './DatePicker'
export * from './UnixTimestamp'
export * from './BooleanToggle'
export * from './NaN'
export * from './Symbol'
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/widgets/ReactDatePicker/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
box-shadow:
rgba(0, 0, 0, 0.1) 0px 10px 15px -3px,
rgba(0, 0, 0, 0.05) 0px 4px 6px -2px;
z-index: 100;
}

.react-datepicker {
Expand Down
Loading