Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 77eef1c816 chore(perf): give the table developer route /app/_/dev a 360 KB cap
CI's bundle budget on the combined tree (SQL Workbench #47, FILE_PREVIEW
#49 and this PR) measured /app/_/dev at 350.8 KB first-load JS gzip, 0.8 KB
over the 350 KB default (each change fit alone). The record developer
route /app/_/_/dev measured 354.9 KB under its 360 KB cap. Give /app/_/dev
the same 360 KB route cap (about 2.6% headroom); every other route keeps
the 350 KB default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:42:45 -05:00
james.maesandClaude Opus 5.5 61be012fc2 Merge main (FILE_PREVIEW #49) into feature/responsive-readable-layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:28:51 -05:00
james.maes bc01d10a27 Merge pull request #49 from QRun-IO/feature/image-preview-lightbox
feat(preview): FILE_PREVIEW thumbnails and preview lightbox (QRun-IO/qqq#1056)
2026-10-11 18:28:38 -05:00
james.maesandClaude Opus 5.5 d3daa4d067 Merge SQL Workbench (#47) and FILE_PREVIEW (#49) into feature/responsive-readable-layout
Combines #49's preview handling with #50's compact columns:
- ChildRecordListWidget keeps #50's measured compact columns, long-text
  clamp and top-aligned cells, and gains #49's FILE_PREVIEW gallery
  (shared cellRecords, joined-field preview references, child table
  variant). The gallery covers only the columns on screen, so lightbox
  navigation never steps into a hidden column. A preview column in the
  first position is not turned into the row link (as in #49).
- ChildFieldMetaData.adornments is typed as FieldAdornment[] so it serves
  both CHIP detection (#50) and FILE_PREVIEW (#49).
- DataCell / FieldValue keep both DateTimeText and the preview imports.
- perf-budget.json: one shared 360 KB /app/_/_/dev cap; the note records
  the SQL Workbench, FILE_PREVIEW and #50/#53 measurements (expected
  about 355 KB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:35:17 -05:00
james.maesandClaude Opus 5.5 9150fb732f chore(perf): share the developer-route budget note with PR #53
Same 360 KB /app/_/_/dev cap; the note now records both measurements (this
PR 351.1 KB, home app PR #53 350.1 KB, main 350.0 KB) so both PRs carry an
identical perf-budget.json and merge cleanly in either order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:11:14 -05:00
james.maesandClaude Opus 5.5 984c26b62b Merge origin/main (Next UI 1.0.0-RC.12) into feature/responsive-readable-layout
Resolves docs/releases/unreleased.md: RC.12 moved the earlier product fixes
into its release notes; this branch's three entries are the only unreleased
product fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:10:04 -05:00
james.maesandClaude Opus 5.5 310c23f9cd fix(layout): apply the 24rem default record field threshold
The previous commit added the test but missed the constant itself: default
record fields now pair from a 24rem grid (tablet REC-004).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:55:24 -05:00
james.maesandClaude Opus 5.5 de387a61f7 fix(layout): pair default record fields from a 24rem grid
On a tablet the selected-section field grid beside the sidebar is ~416px, below
the 30rem form threshold, so two default-width read-only fields stacked where
they used to share a row (acceptance REC-004 on the tablet profile). Read-only
label/value pairs fit at ~180px each, so the default record field layout pairs
them from 24rem; phones still stack and explicit gridColumns keep the form
thresholds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:55:01 -05:00
james.maesandClaude Opus 5.5 c064048c6f chore(perf): give the record developer route its own 360 KB first-load cap
Measured with `QQQ_NEXT_OUTPUT=export next build` + scripts/perf-budget.mjs:

- main b0b0d46: /app/_/_/dev 350.0 KB gzip (exactly at the 350 KB default cap)
- this branch:  /app/_/_/dev 351.1 KB (+1.1 KB: compact date formatter,
  child-list column ranking, container-query span tables)
- other routes stay under the unchanged 350 KB default (largest /app/_/dev
  347.1 KB); total JS 966.6 KB of 1050 KB; CSS 20.0 KB of 21 KB

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:49:23 -05:00
james.maesandClaude Opus 5.5 2b4d82309c test(acceptance): assert compact dates and the key-behind-toggle child list
- Record-view DATE and DATE_TIME values show compactly; assertions now check
  the text and the full zoned timestamp in the tooltip (or the <time dateTime>
  calendar day for DATE).
- A value in a declared zone other than the viewer's keeps its time and zone
  even in an older year (`Mar 10, 2024, 3:30 AM CDT`), so fixed- and
  per-record-zone fields still read in their zone (REC-019).
- Audit entries and script log/line timestamps: compact text, full title.
- WID-024: the key column is behind the "more columns" toggle while the name
  identifies the row; the toggle restores Id + Name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:48:52 -05:00
james.maesandClaude Opus 5.5 4fc3dfd7aa docs: note responsive layout, readable child lists and compact dates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:43:57 -05:00
james.maesandClaude Opus 5.5 8c9619e7f7 test(mocks): CMS app with wide child lists for layout and date checks
Adds a mock CMS app (sites with domain and blog child lists, long descriptions
and meta tags, zoned timestamps including "today") so record views, embedded
child lists, the query grid and an app home can be checked at phone, laptop,
desktop and ultra-wide widths with `NEXT_PUBLIC_MOCK_API=true pnpm dev`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:43:00 -05:00
james.maesandClaude Opus 5.5 d3298cca2b feat(child-records): readable embedded child lists that fit their panel
A child-record list in a record-view panel showed every field of the child
table, so long text wrapped into tall, narrow columns. It now measures its
width and shows the columns that matter most and fit, ranked from metadata and
the rows on screen (compact-columns):

1. the record's name (first short text column, preferring T1 sections)
2. status columns (CHIP / possible values) and booleans
3. one date: the date column with the latest value (modified beats created)
4. other short text, numbers, other dates
5. long text last (TEXT / HTML / heavy, or values that run long in the rows)

The primary key is left out while a name column identifies the row (the row
still links to its record); it leads only when nothing else names the row. An
application that sets the widget's existing `onlyIncludeFieldNames` has chosen
its columns, and that order is their priority, so no new QQQ metadata is needed.

Hidden columns are one click away (`+N more columns` / `Fewer columns`, plus
View All). Cells align to the top, long text is clamped to three lines with the
full text on hover, and headers stay on one line. The CSV export keeps every
column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:42:59 -05:00
james.maesandClaude Opus 5.5 edaa1ffb33 feat(layout): size record, dashboard and form grids by their own width
QQQ sizes cards, widgets and fields in twelfths (gridColumns). Applied at the
viewport's `lg` breakpoint, a half-width card is half of what is left after the
sidebar, so two table panels sat side by side at ~350px on a laptop and stayed
side by side however narrow they got.

Grids are now CSS containers (`@container`) and their items take their declared
span only when the grid itself is wide enough for items of that kind:

- record-view cards (tables, field lists): stacked below 66rem, half from 66rem,
  at least a third from 100rem, the declared span from 120rem; overview cards
  keep their own height instead of stretching to the tallest in the row
- dashboard widgets: 40rem / 60rem / 76rem; table widgets use the card widths
- form and record fields: 30rem / 44rem / 56rem, so a create dialog or a narrow
  panel no longer crams three fields into a row; the default two-column record
  field layout widens to thirds and quarters on ultra-wide screens
- the record header's identity fields flow in a row, each as wide as its value

Material per-breakpoint widget sizes (gridCols:sizeClass:*) keep their
viewport behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:42:47 -05:00
james.maesandClaude Opus 5.5 c564a29ce8 feat(dates): one compact date/time presentation for every table, record view and widget
A shared formatter (compactDateTime / compactDate / relativeDateTime) and a
DateTimeText component replace the long `yyyy-MM-dd hh:mm:ss AM TZ` text that
wrapped over three lines in narrow cells:

- today: `6:33 PM`; this year: `Jul 5, 6:33 PM`; before: `Jul 5, 2025`
- DATE values never show a time and are never shifted by the viewer zone
  (DataCell read `2024-06-15` as UTC midnight and showed the 14th in the US)
- one line (nowrap, tabular figures), the full zoned timestamp as the tooltip,
  a machine-readable <time dateTime>
- the viewer's zone and locale; a backend display value zoned by a field
  behavior in another zone keeps that zone (`Jul 5, 4:33 PM PDT`)
- relative time (`3h ago`) for activity lists (ESB messages and counters)
- grid date columns default to widths that fit the compact form

Exports are unchanged: server exports and the child-list CSV still write the
raw or backend display values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:42:35 -05:00
48 changed files with 1676 additions and 184 deletions

No files matched your search

+3 -1
View File
@@ -17,7 +17,9 @@
## Product fixes
None yet.
- Size record-view cards, dashboard widgets and form fields by the width of their own grid (container queries) rather than the viewport: table panels sit side by side only when each is readable and otherwise stack, cards keep their own height, fields share a row only when they fit, and very wide screens use the room. No horizontal page scroll from 360px up.
- Make child-record lists in record-view panels readable: they show the columns that matter most and fit (the record name, status and boolean columns, the most recently changed date; the key and long text last), with `+N more columns` and View All for the rest. Cells align to the top and long text is clamped with the full text on hover. A widget's `onlyIncludeFieldNames` order is respected.
- Show dates compactly everywhere: `6:33 PM` today, `Jul 5, 6:33 PM` this year, `Jul 5, 2025` before, on one line with the full zoned timestamp on hover, in the viewer's zone and locale. DATE values are no longer shifted a day west of UTC. Exports are unchanged.
## Browser testing and infrastructure
+2 -1
View File
@@ -1,5 +1,5 @@
{
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. 2026-10-11 (QRun-IO/qqq#1056): main measured /app/_/_/dev at 350.0 KB, exactly the default cap; FILE_PREVIEW thumbnails add 0.6 KB to the record routes (resolver, thumbnail, gallery context; the lightbox is a lazy chunk), giving 350.6 KB, so that route gets an explicit 360 KB cap (about 2.7% headroom). SQL Workbench (2026-10-11): the qbit-sql-workbench SQL_WORKBENCH widget loads CodeMirror 6 and the workbench UI only through import() from WidgetRenderer, and sql-formatter only when Format is clicked; both are budgeted under lazyChunks. Adding the lazy import measured /app/_/_/dev at 353.1 KB (main 350.0 KB, already at its cap; the same tree without the WidgetRenderer hook-in measures 350.1 KB) (that route's cap was 355 KB on the SQL Workbench branch). Combined with FILE_PREVIEW (+0.6 KB) the expected /app/_/_/dev first load is about 353.7 KB, inside the 360 KB route cap. Raise a cap only with a measured reason in the commit.",
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. 2026-10-11 (QRun-IO/qqq#1056): main measured /app/_/_/dev at 350.0 KB, exactly the default cap; FILE_PREVIEW thumbnails add 0.6 KB to the record routes (resolver, thumbnail, gallery context; the lightbox is a lazy chunk), giving 350.6 KB, so that route gets an explicit 360 KB cap (about 2.7% headroom). SQL Workbench (2026-10-11): the qbit-sql-workbench SQL_WORKBENCH widget loads CodeMirror 6 and the workbench UI only through import() from WidgetRenderer, and sql-formatter only when Format is clicked; both are budgeted under lazyChunks. Adding the lazy import measured /app/_/_/dev at 353.1 KB (main 350.0 KB, already at its cap; the same tree without the WidgetRenderer hook-in measures 350.1 KB) (that route's cap was 355 KB on the SQL Workbench branch). Combined with FILE_PREVIEW (+0.6 KB) the expected /app/_/_/dev first load is about 353.7 KB, inside the 360 KB route cap. Responsive layouts (PR #50): the compact date, child-list column and container-grid work measured 351.1 KB on /app/_/_/dev against main's 350.0 KB (+1.1 KB), and the instance home app (PR #53) 350.1 KB (+0.1 KB). With SQL Workbench, FILE_PREVIEW and PR #50 together the route is expected at about 354.8 KB (355 KB with PR #53), inside the shared 360 KB route cap; every other route keeps the 350 KB default. CI on the combined SQL Workbench + FILE_PREVIEW + PR #50 tree (2026-10-11) measured /app/_/_/dev at 354.9 KB and the table developer route /app/_/dev at 350.8 KB (over the 350 KB default by 0.8 KB), so /app/_/dev gets the same 360 KB route cap (about 2.6% headroom). Raise a cap only with a measured reason in the commit.",
"firstLoadJsGzipKB": 350,
"routes": {
"/": 255,
@@ -14,6 +14,7 @@
"/app/_/_/copy": 390,
"/app/_/_/dev": 360,
"/app/_/_/edit": 350,
"/app/_/dev": 360,
"/app/_/create": 350,
"/app/_/savedView/_": 350
},
+2 -2
View File
@@ -21,7 +21,7 @@
import React from 'react'
import type { EsbCounter } from '@/types'
import { formatDateTime } from '@/lib/utils/datetime-utils'
import { compactDateTime, relativeDateTime } from '@/lib/utils/datetime-utils'
/** Which side of a destination the counters describe. */
export type EsbCountersKind = 'publish' | 'consume'
@@ -59,7 +59,7 @@ export function EsbCounters({ counters, kind }: { counters: EsbCounter; kind: Es
['Max time', `${Math.round(counters.maxMs)} ms`]
)
}
items.push(['Last activity', formatDateTime(counters.lastActivity) ?? '—'])
items.push(['Last activity', relativeDateTime(counters.lastActivity) ?? compactDateTime(counters.lastActivity)?.text ?? '—'])
return (
<dl className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
+2 -2
View File
@@ -30,7 +30,7 @@ import { X } from 'lucide-react'
import type { EsbMessage, EsbPermissions } from '@/types'
import { ESB_PAGE_SIZE, getEsbDeadLetters, getEsbMessages } from '@/lib/api/esb'
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { formatDateTime } from '@/lib/utils/datetime-utils'
import { DateTimeText } from '@/components/ui/DateTimeText'
import {
EsbQueueActions,
EsbSelectionActions,
@@ -223,7 +223,7 @@ function MessageRow({
)}
<td className="px-2 py-2 font-mono text-xs text-foreground">{message.messageId}</td>
<td className="whitespace-nowrap px-2 py-2 text-foreground">
{formatDateTime(message.timestamp) ?? message.timestamp}
<DateTimeText type="DATE_TIME" value={message.timestamp} relative className="text-xs" />
</td>
<td className="px-2 py-2 tabular-nums text-foreground">{message.deliveryCount}</td>
<td className="space-y-1 px-2 py-2">
+2 -2
View File
@@ -417,7 +417,7 @@ export function DynamicForm({
/>
)}
{/* a 12-column grid: full width on phones, half from sm, the field's gridColumns from lg (Material) */}
<div className="grid grid-cols-12 gap-4">
<div className="@container grid grid-cols-12 gap-4">
{gridFields.map((f) => (
<div
key={f.name}
@@ -456,7 +456,7 @@ export function DynamicForm({
{formLabel && (
<h3 className="text-base font-semibold text-foreground">{formLabel}</h3>
)}
<div className="grid grid-cols-12 gap-4">
<div className="@container grid grid-cols-12 gap-4">
{resolvedFields.map((f) => (
<div
key={f.name}
+3 -62
View File
@@ -27,6 +27,7 @@ import Link from 'next/link'
import { sanitizeHtml } from '@/lib/utils/sanitize-html'
import type { QFieldMetaData, QRecord } from '@/types'
import { chipStyle, CHIP_COLOR_CLASSES, fileDownload, linkTarget, tooltipText } from '@/lib/utils/adornment-utils'
import { DateTimeText } from '@/components/ui/DateTimeText'
import { filePreview } from '@/lib/utils/file-preview'
import { FileThumbnail } from '@/components/preview/FileThumbnail'
@@ -192,22 +193,10 @@ export function DataCell({ field, value, displayValue, record }: DataCellProps)
)
}
case 'DATE': {
if (!value) return <EmptyCell fieldName={field.name} />
return (
<span className="text-sm text-foreground" data-qqq-id={`grid-cell-${field.name}`}>
{display || formatDate(String(value))}
</span>
)
}
case 'DATE':
case 'DATE_TIME': {
if (!value) return <EmptyCell fieldName={field.name} />
return (
<span className="text-sm text-foreground" data-qqq-id={`grid-cell-${field.name}`}>
{displayValue && displayValue !== String(value) ? displayValue : formatDateTime(String(value))}
</span>
)
return <DateTimeText type={field.type} value={value} displayValue={displayValue} dataQqqId={`grid-cell-${field.name}`} />
}
case 'PASSWORD': {
@@ -310,51 +299,3 @@ function RevealCell({ value, fieldName }: { value: string; fieldName: string })
</button>
)
}
// ------------------------------------------------------------------
// Utilities
// ------------------------------------------------------------------
// Intl formatters are costly to construct and a 250-row page can hold thousands of date
// cells, so each is built once, on first use (QRun-IO/qqq#710).
let dateFormat: Intl.DateTimeFormat | undefined
let zoneFormat: Intl.DateTimeFormat | undefined
/**
* Formats an ISO date string using the browser's locale date formatting.
*
* Returns the original string unchanged if parsing fails.
*
* @param value - An ISO 8601 date string (e.g., `"2024-06-15"`).
* @returns A locale-formatted date string (e.g., `"6/15/2024"`).
*/
function formatDate(value: string): string {
try {
const d = new Date(value)
if (isNaN(d.getTime())) return value
// the same output as d.toLocaleDateString(), without a new formatter per call
dateFormat ??= new Intl.DateTimeFormat()
return dateFormat.format(d)
} catch {
return value
}
}
/**
* Formats an ISO datetime string using the browser's locale date-time formatting.
*
* Returns the original string unchanged if parsing fails.
*
* @param value - An ISO 8601 datetime string (e.g., `"2024-06-15T14:30:00Z"`).
* @returns A locale-formatted datetime string (e.g., `"6/15/2024, 2:30:00 PM"`).
*/
function formatDateTime(value: string): string {
const d = new Date(value)
if (isNaN(d.getTime())) return value
const pad = (n: number) => String(n).padStart(2, '0')
const hours = d.getHours() % 12 === 0 ? 12 : d.getHours() % 12
zoneFormat ??= new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' })
const zone = zoneFormat.formatToParts(d).find((p) => p.type === 'timeZoneName')?.value ?? ''
// Material's "yyyy-MM-dd hh:mm:ss AM TZ", in the browser's time zone
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(hours)}:${pad(d.getMinutes())}:${pad(d.getSeconds())} ${d.getHours() < 12 ? 'AM' : 'PM'} ${zone}`.trim()
}
@@ -30,6 +30,7 @@ import { cn } from '@/lib/utils/cn'
import { formatDateTime } from '@/lib/utils/datetime-utils'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
import { useAuditRecords } from '@/lib/hooks/use-audit-records'
import { DateTimeText } from '@/components/ui/DateTimeText'
/**
* Props for {@link AuditHistoryDialog}.
@@ -215,7 +216,7 @@ function AuditEntry({ entry, tableMetaData }: { entry: QAuditRecord; tableMetaDa
return (
<li className="rounded-lg border border-border p-3" data-qqq-id={`audit-entry-${entry.id}`}>
<div className="flex flex-wrap items-baseline gap-x-3 text-xs text-muted-foreground">
<time dateTime={entry.timestamp} data-qqq-id="audit-entry-timestamp">{formatDateTime(entry.timestamp) ?? entry.timestamp}</time>
<DateTimeText type="DATE_TIME" value={entry.timestamp} className="text-xs text-muted-foreground" dataQqqId="audit-entry-timestamp" />
{entry.user && <span data-qqq-id="audit-entry-user">{entry.user}</span>}
</div>
{entry.message && <p className="mt-1 text-sm font-medium text-foreground" data-qqq-id="audit-entry-message">{entry.message}</p>}
+15 -5
View File
@@ -109,12 +109,22 @@ describe('FieldValue adornments use the backend value keys', () => {
expect(screen.getByRole('alert')).toHaveTextContent('Error: Could not load widget [nope]')
})
it('shows a zoned DATE_TIME display value as-is and formats a raw instant in the viewer zone', () => {
expect(show(field('fixed', [], { type: 'DATE_TIME' }), { fixed: '2024-03-10T08:30:00Z' }, { fixed: '2024-03-10 03:30:00 AM CDT' }))
.toHaveTextContent('2024-03-10 03:30:00 AM CDT')
it('shows DATE_TIME compactly on one line with the full zoned timestamp as its tooltip', () => {
const fixed = show(field('fixed', [], { type: 'DATE_TIME' }), { fixed: '2024-03-10T08:30:00Z' }, { fixed: '2024-03-10 03:30:00 AM CDT' })
// in a CDT viewer the zone matches (date only); elsewhere the declared zone keeps its time
expect(fixed.textContent).toMatch(/^Mar 10, 2024(, 3:30 AM CDT)?$/)
expect(fixed.getAttribute('title')).toContain('2024-03-10 03:30:00 AM CDT')
const raw = show(field('plain', [], { type: 'DATE_TIME' }), { plain: '2024-03-10T08:30:00Z' }, { plain: '2024-03-10T08:30:00Z' })
expect(raw.textContent).toMatch(/^2024-03-10 \d{2}:30:00 [AP]M \S+$/)
expect(raw).toHaveAttribute('dateTime', '2024-03-10T08:30:00Z')
expect(raw.textContent).toMatch(/^Mar (9|10), 2024$/)
expect(raw.getAttribute('title')).toMatch(/^2024-03-(09|10) \d{2}:30:00 [AP]M \S+$/)
expect(raw).toHaveAttribute('dateTime', '2024-03-10T08:30:00.000Z')
expect(raw).toHaveClass('whitespace-nowrap')
})
it('shows a DATE as its calendar day, never shifted by the viewer zone and never with a time', () => {
const date = show(field('day', [], { type: 'DATE' }), { day: '2024-06-15' })
expect(date).toHaveTextContent('Jun 15, 2024')
expect(date).toHaveAttribute('dateTime', '2024-06-15')
})
it('renders RENDER_HTML sanitized and an empty value as a dash', () => {
+7 -9
View File
@@ -33,7 +33,6 @@ import { useFocusSafeTooltip } from '@/lib/hooks/use-focus-safe-tooltip'
import { cn } from '@/lib/utils/cn'
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
import { isHttpUrl, isEmail } from '@/lib/utils/string-utils'
import { formatDateTime } from '@/lib/utils/datetime-utils'
import { formatJson, formatSql, languageFor } from '@/lib/utils/code-highlight'
import {
attachmentUrl, chipStyle, CHIP_COLOR_CLASSES, fileDownload, findAdornment, linkTarget, tooltipText,
@@ -43,6 +42,7 @@ import { WidgetRenderer } from '@/components/widgets/WidgetRenderer'
import { MetadataIcon } from '@/components/layout/MetadataIcon'
import { HighlightedCode } from '@/components/scripts/HighlightedCode'
import { SeededWidget } from '@/components/widgets/ConnectedWidget'
import { DateTimeText } from '@/components/ui/DateTimeText'
import { FileThumbnail } from '@/components/preview/FileThumbnail'
import { RecordHoverCard } from './RecordHoverCard'
@@ -82,8 +82,8 @@ interface FieldValueProps {
*
* Adornments are applied in the order the QQQ dashboards use (one per value), and
* a TOOLTIP adornment wraps whatever is rendered. Without an adornment, the value
* is formatted by type: DATE_TIME in the viewer's time zone (unless the backend
* supplied a zoned display value), BOOLEAN as Yes/No, TEXT with line breaks, HTML
* is formatted by type: DATE and DATE_TIME compactly in the viewer's zone and locale
* (see DateTimeText), BOOLEAN as Yes/No, TEXT with line breaks, HTML
* sanitized, and everything else as the backend display value. Empty values render
* an em dash.
*
@@ -264,12 +264,10 @@ function FieldValueContent({ field, record, allTables, navigateFrom, widgetMetaD
)
}
case 'DATE_TIME': {
// A zoned display value from the backend (e.g. a fixed or per-record zone) wins;
// otherwise the instant is shown in the viewer's time zone.
const text = displayValue && displayValue !== rawValue ? displayValue : (formatDateTime(rawValue) ?? String(value))
return <time dateTime={typeof rawValue === 'string' ? rawValue : undefined} className={cn('text-sm text-foreground', className)}
data-qqq-id={dataQqqId}>{text}</time>
case 'DATE_TIME':
case 'DATE': {
// compact and on one line, full value on hover; a backend zone other than the viewer's is kept
return <DateTimeText type={field.type} value={rawValue} displayValue={displayValue} className={className} dataQqqId={dataQqqId} />
}
case 'BLOB': {
+3 -3
View File
@@ -342,14 +342,14 @@ export function RecordViewHeader({
</button>
{actionsPlacement === 'INLINE_WITH_PAGE_TITLE' && desktopActions}
</div>
{/* T1 fields as a compact grid under the name */}
{/* T1 fields in a row under the name, each as wide as its value (up to 32rem), wrapping as the header narrows */}
{t1Fields.length > 0 && (
<dl
className="mt-2 grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-3 lg:grid-cols-4"
className="mt-2 flex flex-wrap gap-x-8 gap-y-2"
data-qqq-id="record-primary-sections"
>
{t1Fields.map((field) => (
<div key={field.name} className="flex min-w-0 flex-col" data-qqq-id={`record-field-${field.name}`}>
<div key={field.name} className="flex min-w-[8rem] max-w-[32rem] flex-col" data-qqq-id={`record-field-${field.name}`}>
<dt className="text-xs text-muted-foreground">
<FieldLabel field={field} data-qqq-id={`field-label-${field.name}`} helpKey={`table:${tableMetaData.name};field:${field.name}`} />
</dt>
+3 -2
View File
@@ -27,6 +27,7 @@ import type { QTableMetaData, QTableSection, QRecord, QWidgetMetaData } from '@/
import { associationWidgetBinding } from '@/lib/utils/association-utils'
import { cn } from '@/lib/utils/cn'
import { gridSpanClasses, initialSectionOpen, isCollapsibleSection, recordSectionField, storeSectionOpen, twelfths } from '@/lib/utils/record-layout-utils'
import { DEFAULT_FIELD_SPAN_CLASSES } from '@/lib/utils/responsive-grid'
import { selectSlotHelpContent, VIEW_SCREEN_HELP_ROLES } from '@/lib/utils/help-utils'
import { useHelpHelpActive } from '@/lib/context/q-context'
@@ -273,7 +274,7 @@ export function RecordViewSection({
) : (
/* Explicit field widths use twelfths; the surrounding view supplies the default layout. */
<dl
className={cn('grid grid-cols-12 gap-x-8', defaultFieldColumns === 2 ? 'gap-y-6' : 'gap-y-4')}
className={cn('@container grid grid-cols-12 gap-x-8', defaultFieldColumns === 2 ? 'gap-y-6' : 'gap-y-4')}
>
{visibleFields.map((field) => {
if (!field) return null
@@ -284,7 +285,7 @@ export function RecordViewSection({
'flex min-w-0 flex-col gap-0.5',
twelfths(field.gridColumns) !== undefined
? gridSpanClasses(field.gridColumns!)
: defaultFieldColumns === 2 ? 'col-span-12 sm:col-span-6' : 'col-span-12'
: defaultFieldColumns === 2 ? DEFAULT_FIELD_SPAN_CLASSES : 'col-span-12'
)}
data-qqq-id={`record-field-${field.name}`}
>
+6 -4
View File
@@ -232,10 +232,12 @@ export function RecordViewTabs({
</div>
</div>
{/* Tab content: Overview — Material's 12-column card widths */}
{/* Tab content: Overview — Material's 12-column card widths, applied by the panel's own
width (container queries) so cards sit side by side only when each is readable; cards
keep their own height instead of stretching to the tallest in the row */}
{activeTab === 'overview' && (
<div
className={cn('grid grid-cols-12 gap-6', panelFocus)}
className={cn('@container grid grid-cols-12 items-start gap-6', panelFocus)}
{...panelAttributes}
data-qqq-id="record-tab-panel-overview"
>
@@ -270,7 +272,7 @@ export function RecordViewTabs({
className={panelFocus}
data-qqq-id={`record-tab-panel-${section.name}`}
>
<div className="grid grid-cols-12 gap-6">
<div className="@container grid grid-cols-12 gap-6">
<div className={cn('rounded-xl border border-border bg-card p-6 shadow-sm', recordSectionGridSpan(section, widgetMetaDataMap, 12))}>
<RecordViewSection
section={section}
@@ -297,7 +299,7 @@ export function RecordViewTabs({
className={panelFocus}
data-qqq-id={`record-tab-panel-${section.name}`}
>
<div className="grid grid-cols-12 gap-6">
<div className="@container grid grid-cols-12 gap-6">
<div className={cn('rounded-xl border border-border bg-card p-6 shadow-sm', recordSectionGridSpan(section, widgetMetaDataMap, 12))}>
<RecordViewSection
section={section}
+2 -1
View File
@@ -32,6 +32,7 @@ import { cn } from '@/lib/utils/cn'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { CodeBlock } from './HighlightedCode'
import { dateTimeText, domId, text } from './script-utils'
import { DateTimeText } from '@/components/ui/DateTimeText'
export const BUTTON_CLASS = cn(
'inline-flex items-center justify-center gap-2 rounded-md border border-input bg-card px-3 py-1.5 text-sm font-medium text-foreground',
@@ -202,7 +203,7 @@ export function ScriptTest({ idKey, scriptId, files, inputFields, outputFields,
<tbody>
{result.logLines.map((line, index) => (
<tr key={index} className="border-t border-border align-top">
<td className="whitespace-nowrap px-3 py-2 text-foreground">{dateTimeText(line.timestamp)}</td>
<td className="whitespace-nowrap px-3 py-2 text-foreground"><DateTimeText type="DATE_TIME" value={line.timestamp} /></td>
<td className="whitespace-pre-wrap px-3 py-2 text-foreground">{line.text}</td>
</tr>
))}
+2 -1
View File
@@ -41,6 +41,7 @@ import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { languageFor } from '@/lib/utils/code-highlight'
import { cn } from '@/lib/utils/cn'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { DateTimeText } from '@/components/ui/DateTimeText'
import { CodeBlock } from './HighlightedCode'
import { ScriptEditorDialog } from './ScriptEditorDialog'
import { ALERT_CLASS, BUTTON_CLASS, INPUT_CLASS, ScriptDocs, ScriptTest, type ScriptTestFields } from './ScriptPanels'
@@ -509,7 +510,7 @@ function ScriptLogs({ idKey, source, scriptRevisionId }: {
const hadError = log.values.hadError
return (
<tr key={text(log.values.id)} className="border-t border-border align-top" data-qqq-id={`script-log-${text(log.values.id)}`}>
<td className="whitespace-nowrap px-3 py-2 text-foreground">{dateTimeText(log.values.startTimestamp)}</td>
<td className="whitespace-nowrap px-3 py-2 text-foreground"><DateTimeText type="DATE_TIME" value={log.values.startTimestamp} /></td>
<td className="px-3 py-2 text-right text-foreground">{text(log.values.runTimeMillis)}</td>
<td className={cn('px-3 py-2', hadError === true ? 'text-destructive' : 'text-foreground')}>
{hadError === true ? 'Yes' : hadError === false ? 'No' : ''}
+51
View File
@@ -0,0 +1,51 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { DateTimeText } from './DateTimeText'
afterEach(() => {
vi.useRealTimers()
})
describe('DateTimeText', () => {
it('renders one compact, unwrapped line with the full timestamp as its tooltip', () => {
render(<DateTimeText type="DATE_TIME" value="2024-03-10T08:30:00Z" dataQqqId="when" />)
const time = screen.getByText(/2024/)
expect(time.tagName).toBe('TIME')
expect(time.textContent).toMatch(/^Mar (9|10), 2024$/)
expect(time).toHaveAttribute('dateTime', '2024-03-10T08:30:00.000Z')
expect(time.getAttribute('title')).toMatch(/^2024-03-(09|10) \d{2}:30:00 [AP]M \S+$/)
expect(time).toHaveClass('whitespace-nowrap')
expect(time).toHaveAttribute('data-qqq-id', 'when')
})
it('shows recent activity as relative time when asked', () => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-11T16:20:00Z'))
render(<DateTimeText type="DATE_TIME" value="2026-10-11T13:20:00Z" relative />)
expect(screen.getByText('3h ago')).toBeInTheDocument()
})
it('never shows a time for a DATE and shows a dash for an empty value', () => {
const { container } = render(<><DateTimeText type="DATE" value="2024-06-15" /><DateTimeText type="DATE" value={null} /></>)
expect(screen.getByText('Jun 15, 2024')).toHaveAttribute('dateTime', '2024-06-15')
expect(container).toHaveTextContent('—')
})
})
+71
View File
@@ -0,0 +1,71 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file DateTimeText — the one way a DATE or DATE_TIME value is shown on screen: compact,
* on one line, with the full value in a tooltip (see `compactDateTime` / `compactDate`).
*/
'use client'
import React from 'react'
import { compactDate, compactDateTime, relativeDateTime } from '@/lib/utils/datetime-utils'
import { cn } from '@/lib/utils/cn'
/** Props for {@link DateTimeText}. */
export interface DateTimeTextProps {
/** Raw value: an ISO-8601 instant (DATE_TIME) or `yyyy-MM-dd` (DATE). */
value: unknown
/** Backend display value, used for zoned display values and unreadable raw values. */
displayValue?: unknown
/** The field type; DATE never shows a time. */
type: 'DATE' | 'DATE_TIME'
/** Show recent DATE_TIME values as relative time (`3h ago`), e.g. in activity lists. */
relative?: boolean
/** `data-qqq-id` of the element. */
dataQqqId?: string
/** Extra classes. */
className?: string
/** Shown when the value is empty (default: an em dash). */
empty?: React.ReactNode
}
/**
* Renders a date or timestamp compactly and without wrapping: `6:33 PM` today,
* `Jul 5, 6:33 PM` this year, `Jul 5, 2025` before (DATE: `Jul 5` / `Jul 5, 2025`), in the
* viewer's zone and locale, with the full value as the tooltip and a machine-readable
* `<time dateTime>`.
*
* @param props - See {@link DateTimeTextProps}.
* @returns The `<time>` element, or the empty placeholder.
*/
export function DateTimeText({ value, displayValue, type, relative = false, dataQqqId, className, empty }: DateTimeTextProps) {
const display = type === 'DATE' ? compactDate(value, displayValue) : compactDateTime(value, displayValue)
if (!display) {
return empty !== undefined ? <>{empty}</> : <span className={cn('text-sm text-muted-foreground', className)} data-qqq-id={dataQqqId}>—</span>
}
const text = (relative && type === 'DATE_TIME' && relativeDateTime(value)) || display.text
return (
<time
dateTime={display.dateTime}
title={display.title}
className={cn('whitespace-nowrap text-sm tabular-nums text-foreground', className)}
data-qqq-id={dataQqqId}
>
{text}
</time>
)
}
+2 -2
View File
@@ -246,9 +246,9 @@ export function AppHome({ appMetaData, instance, widgetRegistry }: AppHomeProps)
{/* Widget grid: QQQ sizes widgets in twelfths (gridColumns), full width on small screens */}
{widgetItems.length > 0 && (
<section aria-label="Dashboard widgets" className="grid grid-cols-12 gap-5" data-qqq-id="widget-grid">
<section aria-label="Dashboard widgets" className="@container grid grid-cols-12 gap-5" data-qqq-id="widget-grid">
{widgetItems.map((meta) => (
<div key={meta.name} id={meta.name} className={`${widgetColumnClasses(meta.gridColumns, meta.defaultValues)} ${WIDGET_ANCHOR}`} data-qqq-id={`widget-grid-item-${meta.name}`}>
<div key={meta.name} id={meta.name} className={`${widgetColumnClasses(meta.gridColumns, meta.defaultValues, meta.type)} ${WIDGET_ANCHOR}`} data-qqq-id={`widget-grid-item-${meta.name}`}>
<ConnectedWidget widgetMetaData={meta} widgetRegistry={widgetRegistry} />
</div>
))}
@@ -29,7 +29,7 @@ vi.mock('./widget-utils', async (importOriginal) => ({
downloadText: (fileName: string, text: string) => { downloads.calls.push([fileName, text]) },
}))
import type { QRecord, QWidgetMetaData } from '@/types'
import type { FieldAdornment, QRecord, QWidgetMetaData } from '@/types'
import {
ChildRecordListWidget, addChildHref, childColumns, childExportTitle, childRecordsCsv, listColumns, nextViewAllHref, shownColumns,
} from './ChildRecordListWidget'
@@ -119,7 +119,7 @@ describe('ChildRecordListWidget', () => {
localStorage.setItem('qqq.tableVariant.media', JSON.stringify(variant))
try {
instance.value = { tables: { media: { name: 'media', label: 'Media', usesVariants: true, readPermission: true, fields: {} } }, widgets: {} }
const adornments = [
const adornments: FieldAdornment[] = [
{ type: 'FILE_PREVIEW', values: { fileNameField: 'name', mimeTypeField: 'type' } },
{ type: 'FILE_DOWNLOAD', values: {} },
]
@@ -312,3 +312,91 @@ describe('ChildRecordListWidget Material parity (WID-072)', () => {
expect(screen.getByRole('button', { name: 'Export' })).toBeDisabled()
})
})
describe('ChildRecordListWidget in a narrow panel', () => {
const longText = 'Notes from the work of building systems, making decisions and understanding what changes along the way.'
const wideTable: ChildTableMetaData = {
name: 'blog',
label: 'Blogs',
primaryKeyField: 'id',
fields: {
id: { name: 'id', label: 'Id', type: 'INTEGER' },
siteId: { name: 'siteId', label: 'Site', type: 'INTEGER' },
name: { name: 'name', label: 'Name', type: 'STRING' },
isLive: { name: 'isLive', label: 'Is Live', type: 'BOOLEAN' },
description: { name: 'description', label: 'Description', type: 'STRING', maxLength: 1000 },
metaDescription: { name: 'metaDescription', label: 'Meta Description', type: 'STRING', maxLength: 1000 },
createDate: { name: 'createDate', label: 'Create Date', type: 'DATE_TIME' },
modifyDate: { name: 'modifyDate', label: 'Modify Date', type: 'DATE_TIME' },
},
sections: [{ name: 'identity', tier: 'T1', fieldNames: ['id', 'siteId', 'name', 'isLive', 'description', 'metaDescription', 'createDate', 'modifyDate'] }],
}
const widePayload: ChildRecordListPayload = {
type: 'childRecordList',
childFrontendTableMetaData: wideTable,
defaultValuesForNewChildRecords: { siteId: 1 },
queryOutput: {
records: [{
tableName: 'blog',
values: { id: 7, siteId: 1, name: 'Field Notes', isLive: true, description: longText, metaDescription: longText, createDate: '2025-07-05T23:33:29Z', modifyDate: '2026-10-10T11:01:39Z' },
displayValues: { id: '7', name: 'Field Notes' },
}],
},
}
/**
* Gives every observed element the given content width, as a browser would report it.
*
* @param width - Width in CSS pixels.
*/
function stubResizeObserver(width: number) {
vi.stubGlobal('ResizeObserver', class {
private readonly callback: ResizeObserverCallback
constructor(callback: ResizeObserverCallback) { this.callback = callback }
observe(target: Element) { this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver) }
unobserve() {}
disconnect() {}
})
}
/** @returns The headers of the rendered list. */
function headers(): string[] {
return screen.getAllByRole('columnheader').map((header) => header.textContent ?? '')
}
it('shows every column when its width is unknown', () => {
vi.stubGlobal('ResizeObserver', undefined)
render(<ChildRecordListWidget widgetMetaData={meta} data={widePayload} />)
expect(headers()).toEqual(['Id', 'Name', 'Is Live', 'Description', 'Meta Description', 'Create Date', 'Modify Date'])
vi.unstubAllGlobals()
})
it('keeps the name, flags and latest date that fit, and reveals the rest on request', async () => {
stubResizeObserver(400)
render(<ChildRecordListWidget widgetMetaData={meta} data={widePayload} />)
await waitFor(() => expect(headers()).toEqual(['Name', 'Is Live', 'Modify Date']))
// the first shown column links the row; cells align to the top; dates stay on one line
expect(screen.getByRole('link', { name: 'Field Notes' })).toHaveAttribute('href', '/app/blog/7')
expect(screen.getAllByRole('cell')[0]).toHaveClass('align-top')
expect(screen.getAllByRole('cell')[2].querySelector('time')).toHaveClass('whitespace-nowrap')
fireEvent.click(screen.getByRole('button', { name: '+4 more columns' }))
expect(headers()).toEqual(['Id', 'Name', 'Is Live', 'Description', 'Meta Description', 'Create Date', 'Modify Date'])
// long text is clamped, with the full text on hover
const description = screen.getAllByRole('cell')[3].firstElementChild
expect(description).toHaveClass('line-clamp-3')
expect(description).toHaveAttribute('title', longText)
fireEvent.click(screen.getByRole('button', { name: 'Fewer columns' }))
expect(headers()).toEqual(['Name', 'Is Live', 'Modify Date'])
vi.unstubAllGlobals()
})
it('follows the application column order when onlyIncludeFieldNames is set', async () => {
stubResizeObserver(300)
render(<ChildRecordListWidget widgetMetaData={meta} data={{ ...widePayload, onlyIncludeFieldNames: ['description', 'name'] }} />)
await waitFor(() => expect(headers()).toEqual(['Description']))
expect(screen.getByRole('button', { name: '+1 more column' })).toBeInTheDocument()
vi.unstubAllGlobals()
})
})
@@ -24,16 +24,23 @@
* click anywhere on a row to open the child record, a CSV export of the rows shown,
* and a View All link when the backend limited the rows (Material RecordGridWidget).
* Association editing is handled by the record view, not here.
*
* The list adapts to the width it is given (a half-width panel, a phone): it shows the
* columns that matter most and fit (see `compact-columns`), with a toggle to show every
* column; cells align to the top, long text is clamped with its full text on hover, and
* dates are compact.
*/
'use client'
import React, { useMemo } from 'react'
import React, { useMemo, useState } from 'react'
import Link from 'next/link'
import { Download } from 'lucide-react'
import type { QRecord, QTableMetaData } from '@/types'
import type { FieldAdornment, QRecord, QTableMetaData } from '@/types'
import { useMetaData } from '@/lib/hooks/use-metadata'
import { useContainerWidth } from '@/lib/hooks/use-container-width'
import { cn } from '@/lib/utils/cn'
import { columnKind, pickCompactColumns } from '@/lib/utils/compact-columns'
import { hasFilePreview, joinedPreviewField } from '@/lib/utils/file-preview'
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
@@ -54,6 +61,10 @@ interface ChildFieldMetaData {
isHidden?: boolean
isHeavy?: boolean
possibleValueSourceName?: string
/** Declared maximum length (long text when there are no rows to measure). */
maxLength?: number
/** Field adornments (a CHIP marks a status column). */
adornments?: FieldAdornment[]
}
/** An exposed join of the child table (`QFrontendExposedJoin`), with its join table's metadata. */
@@ -312,6 +323,50 @@ export function nextViewAllHref(viewAllLink: string, tableName: string): string
return `/app/${encodeURIComponent(tableName)}${queryIndex >= 0 ? viewAllLink.slice(queryIndex) : ''}`
}
/** The columns an embedded list shows in its width, and which of them hold long text. */
export interface CompactChildColumns {
/** Columns to show, in metadata order. */
compact: ChildColumn[]
/** How many columns did not fit. */
hiddenCount: number
/** Names of the columns holding long text (clamped in their cells). */
longText: Set<string>
}
/**
* Chooses the child columns that matter most and fit the list's width (see `compact-columns`):
* the T1 identity, status and boolean columns and the most recent date before long text, the
* key only when nothing else names the row, and the widget's `onlyIncludeFieldNames` order
* when the application set one.
*
* @param columns - Candidate columns (parent key already removed).
* @param table - Child table metadata.
* @param data - Widget payload.
* @param records - Rows shown.
* @param width - The list's width in CSS pixels; 0 shows every column.
* @returns The choice.
*/
export function compactChildColumns(columns: ChildColumn[], table: ChildTableMetaData | undefined, data: ChildRecordListPayload | undefined, records: QRecord[], width: number): CompactChildColumns {
const valuesOf = (record: QRecord) => (isPlainObject(record?.values) ? record.values : {})
const shownText = (name: string) => records.map((record) => {
const display = isPlainObject(record?.displayValues) ? record.displayValues[name] : undefined
return displayText(display !== undefined && display !== null ? display : valuesOf(record)[name])
})
const rawText = (name: string) => records.map((record) => displayText(valuesOf(record)[name]))
const t1FieldNames = (asList<ChildSectionMetaData>(table?.sections) ?? [])
.filter((section) => isPlainObject(section) && section.tier === 'T1')
.flatMap((section) => asList<string>(section.fieldNames) ?? [])
const context = {
primaryKeyField: table?.primaryKeyField,
t1FieldNames,
explicitOrder: asList<string>(data?.onlyIncludeFieldNames) ?? [],
samples: shownText,
}
const longText = new Set(columns.filter((column) => columnKind(column, context) === 'longText').map((column) => column.name))
const { shown, hidden } = pickCompactColumns(columns, width, context, rawText)
return { compact: shown, hiddenCount: hidden.length, longText }
}
/**
* Renders the child records returned for the hosting record: each value formatted by its
* field's type and adornments (possible values link to their record), the exposed join
@@ -337,17 +392,24 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, onW
const tables = instance?.tables
return listColumns(table, data, (name) => Boolean(tables?.[name]) && tables?.[name]?.readPermission !== false)
}, [valid, table, data, instance?.tables])
const columns = useMemo(() => shownColumns(allColumns, data), [allColumns, data])
const candidateColumns = useMemo(() => shownColumns(allColumns, data), [allColumns, data])
const [measureRef, availableWidth] = useContainerWidth()
const [showAllColumns, setShowAllColumns] = useState(false)
const { compact: columns, hiddenCount, longText } = useMemo(() => {
const rows = isPlainObject(data?.queryOutput) ? asList<QRecord>(data.queryOutput.records) ?? [] : []
return compactChildColumns(candidateColumns, table, data, rows, showAllColumns ? 0 : availableWidth)
}, [candidateColumns, table, data, showAllColumns, availableWidth])
// a join column's values are keyed `joinTable.field`, and so are its FILE_PREVIEW field references
const fields = useMemo(() => Object.fromEntries(columns.map((column) => [column.name, widgetField(
column.name.includes('.') ? joinedPreviewField(column.field, column.name.slice(0, column.name.indexOf('.'))) : column.field, column.name, column.label)])), [columns])
const fields = useMemo(() => Object.fromEntries(candidateColumns.map((column) => [column.name, widgetField(
column.name.includes('.') ? joinedPreviewField(column.field, column.name.slice(0, column.name.indexOf('.'))) : column.field, column.name, column.label)])), [candidateColumns])
// one record per row, shared by its cells and the preview gallery (which matches thumbnails by record)
const cellRecords = useMemo<QRecord[]>(() => (records ?? []).map((record) => ({
tableName: typeof record?.tableName === 'string' ? record.tableName : table?.name ?? '',
values: isPlainObject(record?.values) ? record.values : {},
displayValues: (isPlainObject(record?.displayValues) ? record.displayValues : {}) as Record<string, string>,
})), [records, table?.name])
const galleryFields = useMemo(() => Object.values(fields), [fields])
// only the columns on screen contribute previews, so lightbox navigation never steps into a hidden column
const galleryFields = useMemo(() => columns.map((column) => fields[column.name]).filter(Boolean), [columns, fields])
if (!valid || !table || records === undefined) {
return <WidgetPayloadNotice widgetName={widgetName} message={payloadProblem('child record list', 'queryOutput.records and child table metadata')} />
@@ -367,8 +429,20 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, onW
const allTables = instance?.tables as Record<string, QTableMetaData> | undefined
const showToolbar = data.canAddChildRecord === true || widgetMetaData.showExportButton === true
const columnToggle = hiddenCount > 0 || showAllColumns ? (
<button
type="button"
onClick={() => setShowAllColumns((current) => !current)}
aria-expanded={showAllColumns}
className="rounded text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
data-qqq-id={`child-record-columns-toggle-${widgetName}`}
>
{showAllColumns ? 'Fewer columns' : `+${hiddenCount} more column${hiddenCount === 1 ? '' : 's'}`}
</button>
) : null
return (
<div className="space-y-2" data-qqq-id={`widget-childRecordList-${widgetName}`}>
<div ref={measureRef} className="min-w-0 space-y-2" data-qqq-id={`widget-childRecordList-${widgetName}`} data-hidden-columns={hiddenCount}>
{showToolbar && (
<div className="flex flex-wrap items-center justify-end gap-3">
{widgetMetaData.showExportButton === true && (
@@ -394,7 +468,7 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, onW
<thead>
<tr className="border-b border-border text-left">
{columns.map((column) => (
<th key={column.name} scope="col" className="px-2 py-1.5 font-medium text-muted-foreground" data-qqq-id={`child-record-column-${widgetName}-${column.name}`}>
<th key={column.name} scope="col" className="whitespace-nowrap px-2 py-1.5 align-bottom font-medium text-muted-foreground" data-qqq-id={`child-record-column-${widgetName}-${column.name}`}>
{column.label}
</th>
))}
@@ -419,13 +493,16 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, onW
const display = displayValues[column.name]
const text = displayText(display !== undefined && display !== null ? display : values[column.name])
return (
<td key={column.name} className="px-2 py-1.5 text-foreground" data-qqq-id={`child-record-cell-${widgetName}-${column.name}`}>
<td key={column.name} className="px-2 py-1.5 align-top text-foreground" data-qqq-id={`child-record-cell-${widgetName}-${column.name}`}>
{columnIndex === 0 && href && !hasFilePreview(fields[column.name]) ? (
<Link href={href} data-row-link="" className="text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring">
{text || pkText}
</Link>
) : (
<FieldValue field={fields[column.name]} record={cellRecord} allTables={allTables} widgetMetaDataMap={instance?.widgets} />
// long text is clamped to three lines; the full value is its tooltip
<div className={cn('break-words', longText.has(column.name) ? 'line-clamp-3 min-w-[12rem] max-w-[28rem]' : 'max-w-[20rem]')} title={longText.has(column.name) ? text : undefined}>
<FieldValue field={fields[column.name]} record={cellRecord} allTables={allTables} widgetMetaDataMap={instance?.widgets} />
</div>
)}
</td>
)
@@ -438,9 +515,12 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, onW
</table>
</div>
)}
{(totalRows !== undefined && totalRows > records.length) || viewAllHref ? (
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{totalRows !== undefined && totalRows > records.length ? `Showing ${records.length} of ${totalRows}` : ''}</span>
{(totalRows !== undefined && totalRows > records.length) || viewAllHref || columnToggle ? (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex flex-wrap items-center gap-x-3">
{totalRows !== undefined && totalRows > records.length ? <span>{`Showing ${records.length} of ${totalRows}`}</span> : null}
{columnToggle}
</span>
{viewAllHref && (
<Link
href={viewAllHref}
@@ -152,9 +152,9 @@ export function QqqParentWidget({ widgetMetaData, data, widgetRegistry, childPar
}
return (
<div className="grid grid-cols-12 gap-4" data-qqq-id={`parent-widget-${widgetMetaData.name}`} data-layout="GRID">
<div className="@container grid grid-cols-12 gap-4" data-qqq-id={`parent-widget-${widgetMetaData.name}`} data-layout="GRID">
{children.map((child) => (
<div key={child.name} id={child.name} className={`${widgetColumnClasses(child.gridColumns, child.defaultValues)} ${WIDGET_ANCHOR}`}>
<div key={child.name} id={child.name} className={`${widgetColumnClasses(child.gridColumns, child.defaultValues, child.type)} ${WIDGET_ANCHOR}`}>
<ConnectedWidget
widgetMetaData={child}
params={childParams}
+2 -2
View File
@@ -25,10 +25,10 @@ import { useQuery } from '@tanstack/react-query'
import type { QFieldMetaData } from '@/types'
import { fetchPossibleValues } from '@/lib/api/possible-values'
import { formatDateTime } from '@/lib/utils/datetime-utils'
import { formatPlainValue } from './record-widget-utils'
import type { BuilderRow } from './row-builder-model'
import { DateTimeText } from '@/components/ui/DateTimeText'
/** Props for {@link RowBuilderTable}. */
export interface RowBuilderTableProps {
@@ -76,7 +76,7 @@ function cellContent(field: QFieldMetaData, row: BuilderRow): React.ReactNode {
const inline = field.inlinePossibleValueSource?.enumValues?.find((option) => String(option.id) === String(value))
if (inline) return inline.label
if (field.possibleValueSourceName) return <PossibleValueLabel sourceName={field.possibleValueSourceName} value={value} />
if (field.type === 'DATE_TIME') return formatDateTime(value) ?? formatPlainValue(value, field.type)
if (field.type === 'DATE_TIME' || field.type === 'DATE') return <DateTimeText type={field.type} value={value} />
return formatPlainValue(value, field.type)
}
@@ -26,7 +26,7 @@ import {
describe('widgetColumnClasses with Material per-breakpoint sizes', () => {
it('keeps the gridColumns sizing when no sizeClass is declared', () => {
expect(widgetColumnClasses(6, { other: 1 })).toBe('col-span-12 min-w-0 lg:col-span-6')
expect(widgetColumnClasses(6, { other: 1 })).toBe('col-span-12 @min-[40rem]:col-span-6 min-w-0')
})
it('sizes from the declared breakpoints at Material widths, xs defaulting to 12 and xxl to gridColumns', () => {
@@ -38,7 +38,7 @@ describe('widgetColumnClasses with Material per-breakpoint sizes', () => {
})
it('ignores unusable sizes', () => {
expect(widgetColumnClasses(5, { 'gridCols:sizeClass:md': 40, 'gridCols:sizeClass:lg': 'wide' })).toBe('col-span-12 min-w-0 lg:col-span-5')
expect(widgetColumnClasses(5, { 'gridCols:sizeClass:md': 40, 'gridCols:sizeClass:lg': 'wide' })).toBe('col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-5 min-w-0')
})
})
+12 -5
View File
@@ -73,11 +73,18 @@ describe('dropdown selections', () => {
})
describe('widgetColumnClasses', () => {
it('maps gridColumns (twelfths) to spans, defaulting to full width', () => {
expect(widgetColumnClasses(4)).toBe('col-span-12 min-w-0 lg:col-span-4')
expect(widgetColumnClasses(12)).toBe('col-span-12 min-w-0 lg:col-span-12')
expect(widgetColumnClasses(undefined)).toBe('col-span-12 min-w-0 lg:col-span-12')
expect(widgetColumnClasses(40)).toBe('col-span-12 min-w-0 lg:col-span-12')
it('maps gridColumns (twelfths) to spans by the grid width, defaulting to full width', () => {
expect(widgetColumnClasses(4)).toBe('col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-4 min-w-0')
expect(widgetColumnClasses(3)).toBe('col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-4 @min-[76rem]:col-span-3 min-w-0')
expect(widgetColumnClasses(12)).toBe('col-span-12 min-w-0')
expect(widgetColumnClasses(undefined)).toBe('col-span-12 min-w-0')
expect(widgetColumnClasses(40)).toBe('col-span-12 min-w-0')
})
it('gives table widgets card-sized room before they sit side by side', () => {
expect(widgetColumnClasses(6, undefined, 'childRecordList')).toBe('col-span-12 @min-[66rem]:col-span-6 min-w-0')
expect(widgetColumnClasses(6, undefined, 'table')).toBe('col-span-12 @min-[66rem]:col-span-6 min-w-0')
expect(widgetColumnClasses(6, undefined, 'statistics')).toBe('col-span-12 @min-[40rem]:col-span-6 min-w-0')
})
})
+10 -10
View File
@@ -20,6 +20,7 @@
import type { QHelpContent, QWidgetDropdown, QWidgetHelpContent, QWidgetMetaData } from '@/types'
import { WIDGET_HELP_ROLES, selectSlotHelpContent } from '@/lib/utils/help-utils'
import { containerSpanClasses } from '@/lib/utils/responsive-grid'
/** Root of the local-storage keys that persist widget dropdown selections (shared with Material). */
export const WIDGET_DROPDOWN_STORAGE_ROOT = 'qqq.widgets.dropdownData'
@@ -289,11 +290,8 @@ export function downloadText(fileName: string, text: string, mimeType = 'text/cs
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
}
/** Tailwind classes for QQQ's 12-column widget sizing (static strings for the JIT). */
const COLUMN_SPANS: Record<number, string> = {
1: 'lg:col-span-1', 2: 'lg:col-span-2', 3: 'lg:col-span-3', 4: 'lg:col-span-4', 5: 'lg:col-span-5', 6: 'lg:col-span-6',
7: 'lg:col-span-7', 8: 'lg:col-span-8', 9: 'lg:col-span-9', 10: 'lg:col-span-10', 11: 'lg:col-span-11', 12: 'lg:col-span-12',
}
/** Widget types that render tables, which need card-sized room before they sit side by side. */
const TABLE_WIDGET_TYPES = new Set(['childRecordList', 'table', 'multiTable', 'recordGrid', 'rowBuilder', 'dataBagViewer'])
/** Material Dashboard's grid breakpoints (xs 0, sm 576, md 768, lg 992, xl 1200, xxl 1400 px). */
const SIZE_CLASSES = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'] as const
@@ -320,8 +318,10 @@ function columnSpan(value: unknown): number | undefined {
}
/**
* Column-span classes for a widget in a 12-column grid: full width on small
* screens, `gridColumns` of 12 (default 12, as in Material) on large screens.
* Column-span classes for a widget in a 12-column `@container` grid: full width in a narrow
* grid, `gridColumns` of 12 (default 12, as in Material) once the grid is wide enough for
* widgets of that width to sit in a row; table widgets need more room than charts and
* statistics before they do (see `responsive-grid`).
*
* A widget that declares per-breakpoint sizes (`gridCols:sizeClass:{xs,sm,md,lg,xl,xxl}` in its
* `defaultValues`) is sized as Material sizes it: each declared breakpoint (Material's widths)
@@ -329,9 +329,10 @@ function columnSpan(value: unknown): number | undefined {
*
* @param gridColumns - Widget metadata grid columns.
* @param defaultValues - Widget metadata default values (per-breakpoint overrides).
* @param widgetType - Widget type, so table widgets get card-sized room.
* @returns Tailwind classes.
*/
export function widgetColumnClasses(gridColumns?: number, defaultValues?: Record<string, unknown>): string {
export function widgetColumnClasses(gridColumns?: number, defaultValues?: Record<string, unknown>, widgetType?: string): string {
const sizes = Object.fromEntries(SIZE_CLASSES.map((size) => [size, columnSpan(defaultValues?.[`gridCols:sizeClass:${size}`])]))
if (Object.values(sizes).some((span) => span !== undefined)) {
sizes.xs ??= 12
@@ -339,8 +340,7 @@ export function widgetColumnClasses(gridColumns?: number, defaultValues?: Record
const classes = SIZE_CLASSES.flatMap((size) => (sizes[size] ? [SIZE_CLASS_SPANS[size][sizes[size]]] : []))
return `${classes.join(' ')} min-w-0`
}
const span = columnSpan(gridColumns) ?? 12
return `col-span-12 min-w-0 ${COLUMN_SPANS[span]}`
return containerSpanClasses(columnSpan(gridColumns) ?? 12, widgetType && TABLE_WIDGET_TYPES.has(widgetType) ? 'cards' : 'widgets')
}
/** Classes for a dashboard grid item: an in-page anchor target (`#widgetName`) clear of the header. */
+48
View File
@@ -0,0 +1,48 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file use-container-width — the live content width of an element, for layouts that adapt to
* the space they are given (a panel, a card) rather than to the viewport.
*/
'use client'
import { useCallback, useEffect, useState } from 'react'
/**
* Tracks an element's content-box width with a `ResizeObserver`.
*
* @returns A callback ref for the element and its width in CSS pixels; 0 until measured, and
* always 0 where `ResizeObserver` is unavailable (callers then keep their full layout).
*/
export function useContainerWidth<T extends HTMLElement = HTMLDivElement>(): [(element: T | null) => void, number] {
const [element, setElement] = useState<T | null>(null)
const [width, setWidth] = useState(0)
const ref = useCallback((node: T | null) => setElement(node), [])
useEffect(() => {
if (!element || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver((entries) => {
const next = Math.round(entries[0]?.contentRect.width ?? 0)
// ignore sub-pixel jitter so a re-layout cannot loop on its own size change
setWidth((previous) => (Math.abs(previous - next) < 2 ? previous : next))
})
observer.observe(element)
return () => observer.disconnect()
}, [element])
return [ref, width]
}
+151
View File
@@ -0,0 +1,151 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest'
import { columnKind, estimateColumnWidth, pickCompactColumns, rankColumns } from './compact-columns'
import type { CompactColumn, CompactColumnContext } from './compact-columns'
/**
* A column candidate.
*
* @param name - Field name.
* @param type - Field type.
* @param extra - Extra field metadata.
* @returns The column.
*/
function column(name: string, type = 'STRING', extra: Partial<CompactColumn['field']> = {}): CompactColumn {
return { name, label: name.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase()), field: { name, type, ...extra } }
}
const fieldNotes = 'Notes from the work of building systems, making decisions and understanding what changes along the way.'
// a blog list shaped like the CMS screenshot that prompted this: every column crammed into a panel
const blogColumns = [
column('id', 'INTEGER'),
column('name', 'STRING', { maxLength: 255 }),
column('shortName', 'STRING', { maxLength: 100 }),
column('slug', 'STRING', { maxLength: 100 }),
column('blogTypeId', 'INTEGER', { possibleValueSourceName: 'blogType' }),
column('description', 'STRING', { maxLength: 1000 }),
column('metaTitle', 'STRING', { maxLength: 255 }),
column('metaDescription', 'STRING', { maxLength: 1000 }),
column('metaKeywords', 'STRING', { maxLength: 1000 }),
column('createDate', 'DATE_TIME'),
column('modifyDate', 'DATE_TIME'),
]
const blogValues: Record<string, string[]> = {
id: ['1', '2'],
name: ['Field Notes', 'Ideas Into Systems'],
shortName: ['Field Notes', 'Ideas Into Systems'],
slug: ['quick-bits', 'ideas-into-systems'],
blogTypeId: ['Site Blog', 'Site Blog'],
description: [fieldNotes, fieldNotes],
metaTitle: ['Field Notes - Engineering & Leadership', 'Ideas Into Systems - A CTO\'s Perspective'],
metaDescription: [fieldNotes, fieldNotes],
metaKeywords: ['engineering, leadership, observations, systems', 'technology, architecture, leadership, engineering, systems'],
createDate: ['2026-07-05T23:33:29Z', '2026-07-05T23:33:29Z'],
modifyDate: ['2026-10-10T11:01:39Z', '2026-10-11T07:36:21Z'],
}
const blogContext: CompactColumnContext = {
primaryKeyField: 'id',
t1FieldNames: ['id', 'name', 'shortName', 'slug', 'blogTypeId'],
samples: (name) => blogValues[name] ?? [],
}
describe('columnKind', () => {
it('classifies keys, statuses, booleans, dates, numbers and long text from metadata', () => {
expect(columnKind(column('id', 'INTEGER'), { primaryKeyField: 'id' })).toBe('key')
expect(columnKind(column('status', 'STRING', { adornments: [{ type: 'CHIP' }] }))).toBe('status')
expect(columnKind(column('typeId', 'INTEGER', { possibleValueSourceName: 'type' }))).toBe('status')
expect(columnKind(column('isPrimary', 'BOOLEAN'))).toBe('boolean')
expect(columnKind(column('day', 'DATE'))).toBe('date')
expect(columnKind(column('at', 'DATE_TIME'))).toBe('dateTime')
expect(columnKind(column('total', 'DECIMAL'))).toBe('number')
expect(columnKind(column('body', 'TEXT'))).toBe('longText')
expect(columnKind(column('page', 'HTML'))).toBe('longText')
expect(columnKind(column('blob', 'STRING', { isHeavy: true }))).toBe('longText')
})
it('decides long text from the rows shown, and from the declared length without rows', () => {
expect(columnKind(column('description', 'STRING', { maxLength: 500 }), { samples: () => ['Primary base domain', 'Naked domain, redirect to www'] })).toBe('text')
expect(columnKind(column('description', 'STRING', { maxLength: 100 }), { samples: () => [fieldNotes] })).toBe('longText')
expect(columnKind(column('description', 'STRING', { maxLength: 500 }))).toBe('longText')
expect(columnKind(column('name', 'STRING', { maxLength: 100 }))).toBe('text')
})
})
describe('estimateColumnWidth', () => {
it('sizes dates to their compact form and text to its content within bounds', () => {
expect(estimateColumnWidth(column('createDate', 'DATE_TIME'), 'dateTime')).toBe(150)
expect(estimateColumnWidth(column('day', 'DATE'), 'date')).toBe(130)
expect(estimateColumnWidth(column('name'), 'text', ['ab'])).toBe(90)
expect(estimateColumnWidth(column('name'), 'text', ['x'.repeat(200)])).toBe(260)
expect(estimateColumnWidth(column('body'), 'longText')).toBe(280)
})
})
describe('rankColumns', () => {
it('puts the name first, then status, then the most recently changed date, and long text last', () => {
const ranked = rankColumns(blogColumns, blogContext)
const order = [...ranked].sort((a, b) => b.priority - a.priority).map(({ column: { name } }) => name)
expect(order[0]).toBe('name')
expect(order[1]).toBe('blogTypeId')
// modifyDate holds the latest values, so it is the date that stays
expect(order.indexOf('modifyDate')).toBeLessThan(order.indexOf('createDate'))
// the key ranks below even long text while a name column identifies the row
expect(order.slice(-4).sort()).toEqual(['description', 'id', 'metaDescription', 'metaKeywords'])
expect(ranked.find(({ column: { name } }) => name === 'id')?.kind).toBe('key')
})
it('keeps the key as the row name when no text column names the row', () => {
const ranked = rankColumns([column('id', 'INTEGER'), column('qty', 'INTEGER'), column('at', 'DATE_TIME')], { primaryKeyField: 'id' })
expect([...ranked].sort((a, b) => b.priority - a.priority)[0].column.name).toBe('id')
})
it('follows the application order of onlyIncludeFieldNames', () => {
const ranked = rankColumns(blogColumns, { ...blogContext, explicitOrder: ['metaDescription', 'slug'] })
const order = [...ranked].sort((a, b) => b.priority - a.priority).map(({ column: { name } }) => name)
expect(order.slice(0, 2)).toEqual(['metaDescription', 'slug'])
})
})
describe('pickCompactColumns', () => {
it('shows every column when the width is unknown', () => {
expect(pickCompactColumns(blogColumns, 0, blogContext)).toEqual({ shown: blogColumns, hidden: [] })
})
it('keeps the identity, status and one date in a half-width panel, in metadata order', () => {
const { shown, hidden } = pickCompactColumns(blogColumns, 560, blogContext)
expect(shown.map(({ name }) => name)).toEqual(['name', 'shortName', 'blogTypeId', 'modifyDate'])
expect(hidden.map(({ name }) => name)).toContain('metaDescription')
expect(hidden.map(({ name }) => name)).toContain('id')
})
it('adds columns as the panel widens and shows long text only with room to spare', () => {
const medium = pickCompactColumns(blogColumns, 1000, blogContext).shown.map(({ name }) => name)
expect(medium).toEqual(expect.arrayContaining(['name', 'shortName', 'slug', 'blogTypeId', 'modifyDate']))
expect(medium).not.toContain('metaDescription')
// the key is left out while a name column identifies the row
expect(pickCompactColumns(blogColumns, 4000, blogContext).hidden.map(({ name }) => name)).toEqual(['id'])
})
it('always shows at least the most important column', () => {
expect(pickCompactColumns(blogColumns, 40, blogContext).shown.map(({ name }) => name)).toEqual(['name'])
})
})
+227
View File
@@ -0,0 +1,227 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file compact-columns — which columns an embedded table (a child-record list in a record
* view panel) shows in the width it has.
*
* Every column gets a priority from its field metadata and the rows on screen, and an
* estimated width; the highest-priority columns that fit are shown, in their metadata order:
*
* 1. the record's name: the first short text column, preferring the table's T1 section;
* 2. status-like columns (a CHIP adornment or a possible-value source) and booleans;
* 3. one date: the date column holding the latest value (so "modified" beats "created");
* 4. other short text, then numbers, then other dates;
* 5. the primary key only when nothing else names the row (otherwise it is left out, like the
* parent's key: the row already links to its record);
* 6. long text last (TEXT / HTML / heavy fields, or values that run long in the rows shown).
*
* An application that lists `onlyIncludeFieldNames` on the widget has chosen its columns:
* their order is their priority. Hidden columns stay one click away (the widget's
* "more columns" toggle and View All).
*/
import { DATE_COLUMN_WIDTH, DATE_TIME_COLUMN_WIDTH } from './datetime-utils'
/** The field metadata the column choice reads. */
export interface CompactColumnField {
name: string
type?: string
maxLength?: number
isHeavy?: boolean
possibleValueSourceName?: string
adornments?: Array<{ type?: string }>
}
/** A column candidate: its value key, heading and field. */
export interface CompactColumn {
name: string
label: string
field: CompactColumnField
}
/** What kind of value a column holds, for priority and width. */
export type CompactColumnKind = 'key' | 'label' | 'status' | 'boolean' | 'date' | 'dateTime' | 'number' | 'text' | 'longText'
/** Context for the choice. */
export interface CompactColumnContext {
/** The table's primary key field. */
primaryKeyField?: string
/** Field names in the table's T1 sections (the record's identity). */
t1FieldNames?: string[]
/** Columns the application chose (`onlyIncludeFieldNames`), most important first. */
explicitOrder?: string[]
/** The text of each column's values in the rows shown. */
samples?: (columnName: string) => string[]
}
/** The outcome: the columns to show (metadata order) and the ones left out. */
export interface CompactColumnChoice<T extends CompactColumn> {
shown: T[]
hidden: T[]
}
/** Average and longest sample length above which a text column counts as long text. */
const LONG_TEXT_AVERAGE = 40
const LONG_TEXT_MAX = 80
/** A declared maximum length that marks long text when there are no rows to look at. */
const LONG_TEXT_MAX_LENGTH = 255
/** Approximate width of one character of 14px table text, and a cell's horizontal padding. */
const CHAR_WIDTH = 7
const CELL_PADDING = 16
/**
* Classifies a column.
*
* @param column - The column.
* @param context - Primary key and sample rows.
* @returns The kind (`label` is assigned later, by position).
*/
export function columnKind(column: CompactColumn, context: CompactColumnContext = {}): Exclude<CompactColumnKind, 'label'> {
const { field } = column
if (column.name === context.primaryKeyField) return 'key'
if (field.adornments?.some((adornment) => adornment?.type === 'CHIP') || field.possibleValueSourceName) return 'status'
switch (field.type) {
case 'BOOLEAN': return 'boolean'
case 'DATE': return 'date'
case 'DATE_TIME': return 'dateTime'
case 'INTEGER': case 'LONG': case 'DECIMAL': return 'number'
case 'TEXT': case 'HTML': case 'BLOB': return 'longText'
default: break
}
if (field.isHeavy) return 'longText'
const samples = (context.samples?.(column.name) ?? []).filter((text) => text !== '')
if (samples.length > 0) {
const lengths = samples.map((text) => text.length)
const average = lengths.reduce((sum, length) => sum + length, 0) / lengths.length
return average > LONG_TEXT_AVERAGE || Math.max(...lengths) > LONG_TEXT_MAX ? 'longText' : 'text'
}
return (field.maxLength ?? 0) > LONG_TEXT_MAX_LENGTH ? 'longText' : 'text'
}
/**
* Estimated width a column needs to show its values without crowding.
*
* @param column - The column.
* @param kind - Its kind.
* @param samples - Its values' text in the rows shown.
* @returns Width in CSS pixels.
*/
export function estimateColumnWidth(column: CompactColumn, kind: CompactColumnKind, samples: string[] = []): number {
const heading = Math.min(column.label.length * CHAR_WIDTH + CELL_PADDING, 140)
switch (kind) {
case 'key': case 'number': return Math.max(72, Math.min(heading, 110))
case 'boolean': return Math.max(80, heading)
case 'date': return DATE_COLUMN_WIDTH
case 'dateTime': return DATE_TIME_COLUMN_WIDTH
case 'longText': return 280
default: {
const longest = samples.reduce((max, text) => Math.max(max, text.length), 0)
const content = longest * CHAR_WIDTH + CELL_PADDING
return Math.round(Math.min(260, Math.max(kind === 'status' ? 100 : 90, heading, content)))
}
}
}
/** Base priority of each kind (higher is kept first). */
const KIND_PRIORITY: Record<CompactColumnKind, number> = {
label: 100, status: 70, boolean: 65, dateTime: 40, date: 40, text: 45, number: 35, key: 20, longText: 5,
}
/** Extra priority of the one date column chosen to stay, and of a field in a T1 section. */
const CHOSEN_DATE_BONUS = 25
const T1_BONUS = 5
/**
* The date column whose newest value is the latest (ties: the first), or undefined.
*
* @param candidates - Columns with their kinds.
* @param samples - Sample values per column.
* @returns The chosen date column's name.
*/
function chosenDate(candidates: Array<{ column: CompactColumn; kind: CompactColumnKind }>, samples: (name: string) => string[]): string | undefined {
let best: { name: string; latest: string } | undefined
for (const { column, kind } of candidates) {
if (kind !== 'date' && kind !== 'dateTime') continue
// ISO-8601 values sort as text; display text that is not ISO just keeps metadata order
const latest = samples(column.name).reduce((max, text) => (text > max ? text : max), '')
if (!best || latest > best.latest) best = { name: column.name, latest }
}
return best?.name
}
/**
* Scores every column.
*
* @param columns - Candidates in metadata order.
* @param context - Choice context.
* @param rawSamples - Raw (ISO) values per column, for choosing the date.
* @returns Each column with its kind, priority and estimated width.
*/
export function rankColumns<T extends CompactColumn>(columns: T[], context: CompactColumnContext = {}, rawSamples?: (name: string) => string[]):
Array<{ column: T; kind: CompactColumnKind; priority: number; width: number }> {
const samples = context.samples ?? (() => [])
const kinds = columns.map((column) => ({ column, kind: columnKind(column, context) as CompactColumnKind }))
const t1 = new Set(context.t1FieldNames ?? [])
// the record's name: the first short text column, in a T1 section if there is one
const texts = kinds.filter(({ kind }) => kind === 'text')
const label = (texts.find(({ column }) => t1.has(column.name)) ?? texts[0])?.column.name
for (const entry of kinds) if (entry.column.name === label) entry.kind = 'label'
const date = chosenDate(kinds, rawSamples ?? samples)
const explicit = context.explicitOrder ?? []
return kinds.map(({ column, kind }) => {
let priority = KIND_PRIORITY[kind]
if (explicit.length > 0) {
const index = explicit.indexOf(column.name)
priority = index >= 0 ? 1000 - index : priority
} else {
if (column.name === date) priority += CHOSEN_DATE_BONUS
if (t1.has(column.name) && kind !== 'key') priority += T1_BONUS
// a key is the row's name when nothing else names it, and otherwise not worth the room
if (kind === 'key') priority = label ? -1 : KIND_PRIORITY.label
}
return { column, kind, priority, width: estimateColumnWidth(column, kind, samples(column.name)) }
})
}
/**
* Chooses the columns that fit: the highest priorities first, while their estimated widths fit
* the available width (at least one column is always shown; a negative priority is never used
* to fill space), returned in metadata order.
*
* @param columns - Candidates in metadata order.
* @param availableWidth - The table's width in CSS pixels; 0 or less shows every column.
* @param context - Choice context.
* @param rawSamples - Raw (ISO) values per column, for choosing the date.
* @returns The shown and hidden columns.
*/
export function pickCompactColumns<T extends CompactColumn>(columns: T[], availableWidth: number, context: CompactColumnContext = {}, rawSamples?: (name: string) => string[]): CompactColumnChoice<T> {
if (!(availableWidth > 0)) return { shown: columns, hidden: [] }
const ranked = rankColumns(columns, context, rawSamples)
.map((entry, index) => ({ ...entry, index }))
.sort((a, b) => b.priority - a.priority || a.index - b.index)
const keep = new Set<string>()
let used = 0
for (const { column, width, priority } of ranked) {
if (keep.size > 0 && (priority < 0 || used + width > availableWidth)) continue
keep.add(column.name)
used += width
}
return {
shown: columns.filter((column) => keep.has(column.name)),
hidden: columns.filter((column) => !keep.has(column.name)),
}
}
+82 -1
View File
@@ -16,7 +16,7 @@
import { describe, expect, it } from 'vitest'
import { formatDateTime, fromLocalDateTimeInput, toLocalDateTimeInput } from './datetime-utils'
import { compactDate, compactDateTime, formatDateTime, fromLocalDateTimeInput, relativeDateTime, toLocalDateTimeInput } from './datetime-utils'
describe('formatDateTime', () => {
it('formats an instant in the requested zone with a 12-hour clock and zone abbreviation', () => {
@@ -60,3 +60,84 @@ describe('datetime-local conversion', () => {
expect(toLocalDateTimeInput('garbage')).toBe('')
})
})
describe('compactDateTime', () => {
const now = new Date('2026-10-11T16:20:00Z')
const options = { now, timeZone: 'America/Chicago', locale: 'en-US' }
it('shows only the time for today, month-day-time this year, and the date before', () => {
expect(compactDateTime('2026-10-11T13:05:00Z', undefined, options)?.text).toBe('8:05 AM')
expect(compactDateTime('2026-07-05T23:33:29Z', undefined, options)?.text).toBe('Jul 5, 6:33 PM')
expect(compactDateTime('2025-07-05T23:33:29Z', undefined, options)?.text).toBe('Jul 5, 2025')
})
it('decides today in the viewer zone, not in UTC', () => {
// 02:00 UTC on the 11th is still the evening of the 10th in Chicago
expect(compactDateTime('2026-10-11T02:00:00Z', undefined, options)?.text).toBe('Oct 10, 9:00 PM')
})
it('keeps the full zoned timestamp and the instant for the tooltip and time element', () => {
expect(compactDateTime('2026-07-05T23:33:29Z', undefined, options)).toEqual({
text: 'Jul 5, 6:33 PM', title: '2026-07-05 06:33:29 PM CDT', dateTime: '2026-07-05T23:33:29.000Z',
})
})
it('ignores a backend display value that is the raw value or in the viewer zone', () => {
expect(compactDateTime('2026-07-05T23:33:29Z', '2026-07-05T23:33:29Z', options)?.text).toBe('Jul 5, 6:33 PM')
expect(compactDateTime('2026-07-05T23:33:29Z', '2026-07-05 06:33:29 PM CDT', options)?.text).toBe('Jul 5, 6:33 PM')
})
it('keeps a backend zone that differs from the viewer zone', () => {
const display = compactDateTime('2026-07-05T23:33:29Z', '2026-07-05 04:33:29 PM PDT', options)
expect(display?.text).toBe('Jul 5, 4:33 PM PDT')
expect(display?.title).toBe('2026-07-05 04:33:29 PM PDT\nYour time: 2026-07-05 06:33:29 PM CDT')
// a declared zone is about the time of day, so older values keep their time and zone too
expect(compactDateTime('2025-07-05T23:33:29Z', '2025-07-05 04:33:29 PM PDT', options)?.text).toBe('Jul 5, 2025, 4:33 PM PDT')
})
it('reads zone-less timestamps as local wall-clock time and shows other text as sent', () => {
expect(compactDateTime('2025-01-10T08:00:00', undefined, { now, locale: 'en-US' })?.text).toBe('Jan 10, 2025')
expect(compactDateTime('soon', undefined, options)).toEqual({ text: 'soon', title: 'soon' })
expect(compactDateTime(null, undefined, options)).toBeNull()
expect(compactDateTime('', '', options)).toBeNull()
})
it('follows the locale', () => {
expect(compactDateTime('2026-07-05T23:33:29Z', undefined, { ...options, locale: 'en-GB' })?.text).toBe('5 Jul, 18:33')
})
})
describe('compactDate', () => {
const now = new Date('2026-10-11T16:20:00Z')
it('never shows a time and drops the year only for this year', () => {
expect(compactDate('2026-07-05', undefined, { now, locale: 'en-US' })?.text).toBe('Jul 5')
expect(compactDate('2025-07-05', undefined, { now, locale: 'en-US' })?.text).toBe('Jul 5, 2025')
expect(compactDate('2025-07-05T00:00:00Z', undefined, { now, locale: 'en-US' })?.text).toBe('Jul 5, 2025')
})
it('does not shift the calendar day by the viewer zone', () => {
expect(compactDate('2024-06-15', undefined, { now, timeZone: 'America/Los_Angeles', locale: 'en-US' })).toEqual({
text: 'Jun 15, 2024', title: 'Saturday, June 15, 2024 (2024-06-15)', dateTime: '2024-06-15',
})
})
it('falls back to the display value for anything that is not a date', () => {
expect(compactDate('n/a', 'Not Applicable', { now })).toEqual({ text: 'Not Applicable', title: 'Not Applicable' })
expect(compactDate(undefined, undefined, { now })).toBeNull()
})
})
describe('relativeDateTime', () => {
const now = new Date('2026-10-11T16:20:00Z')
const options = { now, locale: 'en-US' }
it('describes recent instants and leaves older ones to the date formatter', () => {
expect(relativeDateTime('2026-10-11T16:19:40Z', options)).toBe('just now')
expect(relativeDateTime('2026-10-11T16:15:00Z', options)).toBe('5m ago')
expect(relativeDateTime('2026-10-11T13:20:00Z', options)).toBe('3h ago')
expect(relativeDateTime('2026-10-10T12:00:00Z', options)).toBe('yesterday')
expect(relativeDateTime('2026-10-01T12:00:00Z', options)).toBeNull()
expect(relativeDateTime('garbage', options)).toBeNull()
})
})
+242 -4
View File
@@ -16,7 +16,9 @@
/**
* @file datetime-utils — conversions between QQQ DATE_TIME instants (UTC ISO-8601)
* and what a person reads or types in their own time zone.
* and what a person reads or types in their own time zone, and the one compact
* DATE / DATE_TIME presentation every table, record view, widget and card shares
* (see {@link compactDateTime}, {@link compactDate} and {@link relativeDateTime}).
*/
/** A QQQ DATE_TIME wire value: ISO-8601 with an explicit `Z` or offset. */
@@ -58,9 +60,19 @@ function pad(value: number): string {
*/
export function formatDateTime(value: unknown, timeZone?: string): string | null {
const date = parseInstant(value)
if (!date) return null
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', {
timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
return date ? formatInstant(date, timeZone) : null
}
/**
* Formats an instant as `yyyy-MM-dd hh:mm:ss AM TZ` in the given (or the browser's) zone.
*
* @param date - The instant.
* @param timeZone - IANA zone; defaults to the browser's zone.
* @returns The formatted text.
*/
function formatInstant(date: Date, timeZone?: string): string {
const parts = Object.fromEntries(cachedFormat('en-US', timeZone, 'full', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short',
}).formatToParts(date).map((part) => [part.type, part.value]))
const hour = parts.hour === '24' ? '12' : parts.hour
@@ -103,3 +115,229 @@ export function fromLocalDateTimeInput(value: string, originalValue?: unknown):
const date = new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second ?? 0))
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
}
// ---------------------------------------------------------------------------
// Compact presentation
// ---------------------------------------------------------------------------
/** Options shared by the compact formatters (all optional; tests pin them). */
export interface DateDisplayOptions {
/** The reference "now" for today / this-year decisions; defaults to the current time. */
now?: Date
/** IANA zone to show instants in; defaults to the browser's zone. */
timeZone?: string
/** BCP 47 locale for month names and clock style; defaults to the browser's locale. */
locale?: string
}
/** A date or timestamp ready to show: short text, its full form, and the machine value. */
export interface DateDisplay {
/** Compact text, e.g. `6:33 PM`, `Jul 5, 6:33 PM` or `Jul 5, 2025`. */
text: string
/** The full value for a tooltip, e.g. `2026-07-05 06:33:29 PM CDT`. */
title: string
/** A `<time dateTime>` value, when the value could be read. */
dateTime?: string
}
/**
* Width, in CSS pixels, that fits the compact DATE_TIME form (`Sep 30, 12:59 PM`) in a
* 14px table cell with padding, so grids size date-time columns to their content.
*/
export const DATE_TIME_COLUMN_WIDTH = 150
/**
* Width, in CSS pixels, that fits the compact DATE form (`Sep 30, 2025`) in a table cell, with
* room left for a two-word header's sort and menu controls.
*/
export const DATE_COLUMN_WIDTH = 130
/** Formatters are costly to build and a page can show thousands of dates (QRun-IO/qqq#710). */
const formatCache = new Map<string, Intl.DateTimeFormat>()
/**
* A cached `Intl.DateTimeFormat`.
*
* @param locale - Locale, or undefined for the browser's.
* @param timeZone - Zone, or undefined for the browser's.
* @param kind - Cache discriminator for the option set.
* @param options - Format options.
* @returns The formatter.
*/
function cachedFormat(locale: string | undefined, timeZone: string | undefined, kind: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
const key = `${locale ?? ''}|${timeZone ?? ''}|${kind}`
let format = formatCache.get(key)
if (!format) {
format = new Intl.DateTimeFormat(locale, { ...options, timeZone })
formatCache.set(key, format)
}
return format
}
/**
* An instant's calendar day in a zone, as a sortable `yyyy-MM-dd` key.
*
* @param date - The instant.
* @param timeZone - The zone.
* @returns The day key.
*/
function dayKey(date: Date, timeZone: string | undefined): string {
return cachedFormat('en-CA', timeZone, 'day', { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date)
}
/**
* The short zone name (`CDT`, `GMT+2`) of an instant in a zone.
*
* @param date - The instant.
* @param timeZone - The zone.
* @returns The name.
*/
function zoneName(date: Date, timeZone: string | undefined): string {
return cachedFormat('en-US', timeZone, 'zone', { timeZoneName: 'short' }).formatToParts(date).find((part) => part.type === 'timeZoneName')?.value ?? ''
}
/**
* Compact text for an instant: the time today, month and day with the time this year,
* otherwise month, day and year.
*
* @param date - The instant.
* @param now - Reference time.
* @param timeZone - Zone to read both in.
* @param locale - Locale.
* @returns The text.
*/
function compactInstantText(date: Date, now: Date, timeZone: string | undefined, locale: string | undefined): string {
const day = dayKey(date, timeZone)
const today = dayKey(now, timeZone)
if (day === today) return cachedFormat(locale, timeZone, 'time', { hour: 'numeric', minute: '2-digit' }).format(date)
if (day.slice(0, 4) === today.slice(0, 4)) {
return cachedFormat(locale, timeZone, 'monthDayTime', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }).format(date)
}
return cachedFormat(locale, timeZone, 'monthDayYear', { month: 'short', day: 'numeric', year: 'numeric' }).format(date)
}
/** A zone-less ISO date-time (`yyyy-MM-ddTHH:mm[:ss]`), read as wall-clock time in the browser's zone. */
const UNZONED_ISO = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?$/
/**
* A backend display value zoned by a field behavior (QQQ `DateTimeDisplayValueBehavior`,
* `yyyy-MM-dd hh:mm:ss a z`): the wall-clock parts and the zone name.
*/
const BACKEND_ZONED = /^(\d{4})-(\d{2})-(\d{2}) (\d{1,2}):(\d{2})(?::(\d{2}))? ([AP]M) (\S+)$/i
/**
* Reads a DATE_TIME value: a zoned instant, a `Date`, or a zone-less ISO timestamp (local).
*
* @param value - The raw value.
* @returns The instant, or null.
*/
function readDateTime(value: unknown): Date | null {
const instant = parseInstant(value)
if (instant) return instant
if (typeof value !== 'string') return null
const match = UNZONED_ISO.exec(value.trim())
if (!match) return null
const [, year, month, day, hour, minute, second] = match
const date = new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second ?? 0))
return Number.isNaN(date.getTime()) ? null : date
}
/**
* The compact presentation of a DATE_TIME value, in the viewer's zone and locale:
* `6:33 PM` today, `Jul 5, 6:33 PM` this year, `Jul 5, 2025` before; the full
* `yyyy-MM-dd hh:mm:ss AM TZ` timestamp is the title.
*
* A backend display value zoned by a field behavior in a zone other than the viewer's keeps
* that zone and always its time: `Jul 5, 4:33 PM PDT` this year, `Jul 5, 2025, 4:33 PM PDT`
* before. A display value that is not a timestamp at all is shown as sent.
*
* @param value - Raw value (ISO-8601 instant).
* @param displayValue - Backend display value, if any.
* @param options - Reference time, zone and locale.
* @returns The presentation, or null for an empty value.
*/
export function compactDateTime(value: unknown, displayValue?: unknown, options: DateDisplayOptions = {}): DateDisplay | null {
const now = options.now ?? new Date()
const { timeZone, locale } = options
const date = readDateTime(value)
const display = typeof displayValue === 'string' && displayValue.trim() !== '' && displayValue !== value ? displayValue.trim() : undefined
const zoned = display ? BACKEND_ZONED.exec(display) : null
if (zoned && (!date || zoned[8] !== zoneName(date, timeZone))) {
const [, year, month, day, hour12, minute, second, period, zone] = zoned
const hour = (Number(hour12) % 12) + (period.toUpperCase() === 'PM' ? 12 : 0)
// the zone's wall clock, formatted as if it were UTC so no further shift applies
const wall = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), hour, Number(minute), Number(second ?? 0)))
// the viewer's today, at noon on the same wall-clock footing, for the today / this-year choice
const [nowYear, nowMonth, nowDay] = dayKey(now, timeZone).split('-').map(Number)
const wallNow = new Date(Date.UTC(nowYear, nowMonth - 1, nowDay, 12))
const viewer = date ? `\nYour time: ${formatInstant(date, timeZone)}` : ''
// a declared zone is about the time of day, so the time (and its zone) is always kept
const text = year === String(wallNow.getUTCFullYear())
? compactInstantText(wall, wallNow, 'UTC', locale)
: cachedFormat(locale, 'UTC', 'monthDayYearTime', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }).format(wall)
return { text: `${text} ${zone}`, title: `${display}${viewer}`, dateTime: date?.toISOString() }
}
if (date) return { text: compactInstantText(date, now, timeZone, locale), title: formatInstant(date, timeZone), dateTime: date.toISOString() }
const fallback = display ?? (value === null || value === undefined ? '' : String(value).trim())
return fallback ? { text: fallback, title: fallback } : null
}
/** A calendar date (`yyyy-MM-dd`, optionally followed by a time that is ignored). */
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})(?:$|[T ])/
/**
* The compact presentation of a DATE value: `Jul 5` this year, `Jul 5, 2025` otherwise, never
* a time. A DATE is a calendar day, so no zone shifts it (`new Date('2024-06-15')` would read
* UTC midnight and show the 14th west of Greenwich). The title is the long date.
*
* @param value - Raw value (`yyyy-MM-dd`).
* @param displayValue - Backend display value, shown as sent when the raw value is not a date.
* @param options - Reference time, zone (for "this year") and locale.
* @returns The presentation, or null for an empty value.
*/
export function compactDate(value: unknown, displayValue?: unknown, options: DateDisplayOptions = {}): DateDisplay | null {
const now = options.now ?? new Date()
const match = typeof value === 'string' ? DATE_ONLY.exec(value.trim()) : null
if (match) {
const [, year, month, day] = match
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
if (!Number.isNaN(date.getTime())) {
const thisYear = dayKey(now, options.timeZone).slice(0, 4) === year
const text = thisYear
? cachedFormat(options.locale, 'UTC', 'monthDay', { month: 'short', day: 'numeric' }).format(date)
: cachedFormat(options.locale, 'UTC', 'monthDayYear', { month: 'short', day: 'numeric', year: 'numeric' }).format(date)
const long = cachedFormat(options.locale, 'UTC', 'longDate', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }).format(date)
return { text, title: `${long} (${year}-${month}-${day})`, dateTime: `${year}-${month}-${day}` }
}
}
const fallback = typeof displayValue === 'string' && displayValue.trim() !== '' ? displayValue.trim() : value === null || value === undefined ? '' : String(value).trim()
return fallback ? { text: fallback, title: fallback } : null
}
/** Relative-time steps: below the limit (seconds), count in the unit of that many seconds. */
const RELATIVE_STEPS: Array<[limit: number, unit: Intl.RelativeTimeFormatUnit, seconds: number]> = [
[3_600, 'minute', 60],
[86_400, 'hour', 3_600],
[7 * 86_400, 'day', 86_400],
]
/**
* A recent instant as relative time (`just now`, `5m ago`, `3h ago`, `yesterday`, `4d ago`),
* for activity lists; null for instants a week or more away, which read better as dates.
*
* @param value - Raw value (ISO-8601 instant).
* @param options - Reference time and locale.
* @returns The text, or null.
*/
export function relativeDateTime(value: unknown, options: DateDisplayOptions = {}): string | null {
const date = readDateTime(value)
if (!date) return null
const seconds = ((options.now ?? new Date()).getTime() - date.getTime()) / 1000
if (Math.abs(seconds) < 45) return 'just now'
const step = RELATIVE_STEPS.find(([limit]) => Math.abs(seconds) < limit)
if (!step) return null
const [, unit, size] = step
return new Intl.RelativeTimeFormat(options.locale, { style: 'narrow', numeric: 'auto' }).format(-Math.round(seconds / size), unit)
}
+3 -2
View File
@@ -20,14 +20,15 @@ describe('metadata driven form layout', () => {
it('uses a half-width default, valid 12-column spans, and full-width file uploads', () => {
expect(formFieldColumns(field())).toBe(6)
expect(formFieldColumns(field({ gridColumns: 4 }))).toBe(4)
expect(formFieldColumnClasses(field({ gridColumns: 4 }))).toContain('lg:col-span-4')
expect(formFieldColumnClasses(field({ gridColumns: 4 }))).toBe('col-span-12 @min-[30rem]:col-span-6 @min-[44rem]:col-span-4 min-w-0')
expect(formFieldColumnClasses(field())).toBe('col-span-12 @min-[30rem]:col-span-6 min-w-0')
expect(formFieldColumns(field({ gridColumns: 0 }))).toBe(6)
expect(formFieldColumns(field({ gridColumns: 13 }))).toBe(6)
expect(formFieldColumns(field({ gridColumns: Number.NaN }))).toBe(6)
const upload = field({ gridColumns: 3, adornments: [{ type: 'FILE_UPLOAD', values: { width: 'full' } }] })
expect(fileUploadWidth(upload)).toBe('full')
expect(formFieldColumns(upload)).toBe(12)
expect(formFieldColumnClasses(upload)).toContain('sm:col-span-12 lg:col-span-12')
expect(formFieldColumnClasses(upload)).toBe('col-span-12 min-w-0')
expect(fileUploadWidth(field())).toBe('half')
})
+6 -10
View File
@@ -18,20 +18,16 @@
* @file form-layout — how record and process forms size, decorate and transform fields, from
* field metadata, as the Material dashboard does (DynamicForm and DynamicFormField):
*
* - a 12-column grid: full width on phones, half from `sm`, and the field's `gridColumns`
* (default 6, half) from `lg`; a `FILE_UPLOAD` field with `width: full` spans the row from `sm`;
* - a 12-column `@container` grid sized by the form's own width (a dialog or a full page):
* full width when narrow, half from 30rem, and the field's `gridColumns` (default 6, half)
* from 56rem; a `FILE_UPLOAD` field with `width: full` always spans the row;
* - a `$` prefix for a `displayFormat` starting with `$`, and a `%` suffix for one ending in `%%`;
* - live case change for the `TO_UPPER_CASE` and `TO_LOWER_CASE` behaviors.
*/
import type { QFieldMetaData } from '@/types'
import { findAdornment } from './adornment-utils'
/** Large-screen column spans, spelled out so Tailwind generates them. */
const LG_SPANS: Record<number, string> = {
1: 'lg:col-span-1', 2: 'lg:col-span-2', 3: 'lg:col-span-3', 4: 'lg:col-span-4', 5: 'lg:col-span-5', 6: 'lg:col-span-6',
7: 'lg:col-span-7', 8: 'lg:col-span-8', 9: 'lg:col-span-9', 10: 'lg:col-span-10', 11: 'lg:col-span-11', 12: 'lg:col-span-12',
}
import { containerSpanClasses } from './responsive-grid'
/** Default large-screen width of a form field, in twelfths (Material: half). */
export const DEFAULT_FORM_FIELD_COLUMNS = 6
@@ -67,8 +63,8 @@ export function formFieldColumns(field: QFieldMetaData): number {
* @returns Tailwind classes.
*/
export function formFieldColumnClasses(field: QFieldMetaData): string {
const fullFile = Boolean(findAdornment(field, 'FILE_UPLOAD')) && fileUploadWidth(field) === 'full'
return `col-span-12 min-w-0 ${fullFile ? 'sm:col-span-12' : 'sm:col-span-6'} ${LG_SPANS[formFieldColumns(field)]}`
// sized by the form's own width (a dialog, a side panel, a full page), not the viewport
return containerSpanClasses(formFieldColumns(field), 'fields')
}
/** Text shown before and after a numeric input, from its display format. */
+7 -1
View File
@@ -23,6 +23,7 @@
import type { QFieldMetaData, QTableMetaData, QVirtualFieldMetaData } from '@/types'
import { sizeWidth } from './adornment-utils'
import { DATE_COLUMN_WIDTH, DATE_TIME_COLUMN_WIDTH } from './datetime-utils'
/** One column the grid, column chooser and export can use. */
export interface QueryColumn {
@@ -72,12 +73,17 @@ export type ColumnPins = Record<string, ColumnPin>
/**
* Preserve the original Next UI column width unless metadata explicitly sets a SIZE
* adornment. Narrow Material defaults leave too little room for this grid's header controls.
* Date and date-time columns are sized to their compact form (`Jul 5, 2025`, `Jul 5, 6:33 PM`).
*
* @param field - The field.
* @returns The width in pixels.
*/
export function defaultColumnWidth(field: QFieldMetaData): number {
return sizeWidth(field) ?? 150
const declared = sizeWidth(field)
if (declared !== undefined) return declared
if (field.type === 'DATE') return DATE_COLUMN_WIDTH
if (field.type === 'DATE_TIME') return DATE_TIME_COLUMN_WIDTH
return 150
}
/**
+7 -5
View File
@@ -120,12 +120,14 @@ describe('record view layout from metadata', () => {
expect(twelfths(0)).toBeUndefined()
expect(twelfths(13)).toBeUndefined()
expect(twelfths(undefined)).toBeUndefined()
expect(gridSpanClasses(4)).toBe('col-span-12 min-w-0 lg:col-span-4')
expect(gridSpanClasses(99)).toBe('col-span-12 min-w-0 lg:col-span-12')
expect(recordSectionGridSpan(section('fields', { gridColumns: 6 }))).toBe('col-span-12 min-w-0 lg:col-span-6')
expect(recordSectionGridSpan(section('fields'))).toBe('col-span-12 min-w-0 lg:col-span-6')
expect(gridSpanClasses(4)).toBe('col-span-12 @min-[30rem]:col-span-6 @min-[44rem]:col-span-4 min-w-0')
expect(gridSpanClasses(99)).toBe('col-span-12 min-w-0')
// cards sit side by side only when the panel is wide enough for each to hold a table
expect(recordSectionGridSpan(section('fields', { gridColumns: 6 }))).toBe('col-span-12 @min-[66rem]:col-span-6 min-w-0')
expect(recordSectionGridSpan(section('fields'))).toBe('col-span-12 @min-[66rem]:col-span-6 min-w-0')
expect(recordSectionGridSpan(section('fields'), undefined, 12)).toBe('col-span-12 min-w-0')
expect(recordSectionGridSpan(section('widget', { gridColumns: 4, widgetName: 'summary' }), {
summary: { name: 'summary', label: 'Summary', hasPermission: true, gridColumns: 3 },
})).toBe('col-span-12 min-w-0 lg:col-span-3')
})).toBe('col-span-12 @min-[66rem]:col-span-6 @min-[100rem]:col-span-4 @min-[120rem]:col-span-3 min-w-0')
})
})
+8 -10
View File
@@ -24,6 +24,7 @@
import type { QFieldMetaData, QInstance, QTableMetaData, QTableSection, QueryJoin, QWidgetMetaData } from '@/types'
import { MATERIAL_DASHBOARD_TYPE, materialDashboardTableMetaData } from './goto-utils'
import { withReadableExposedJoins } from './query-columns'
import { containerSpanClasses } from './responsive-grid'
/** Section alternative type for the record view screen. */
export const RECORD_VIEW_ALTERNATIVE = 'RECORD_VIEW'
@@ -197,12 +198,6 @@ export function recordViewActionsPlacement(instance: QInstance | undefined, tabl
return fromInstance ?? asPlacement(materialDashboardTableMetaData(table)?.recordViewActionsPlacement) ?? 'IN_IDENTITY_SECTION'
}
/** `lg:col-span-N` for each width of a 12-column grid (static, so Tailwind emits them). */
const LG_SPANS: Record<number, string> = {
1: 'lg:col-span-1', 2: 'lg:col-span-2', 3: 'lg:col-span-3', 4: 'lg:col-span-4', 5: 'lg:col-span-5', 6: 'lg:col-span-6',
7: 'lg:col-span-7', 8: 'lg:col-span-8', 9: 'lg:col-span-9', 10: 'lg:col-span-10', 11: 'lg:col-span-11', 12: 'lg:col-span-12',
}
/**
* A metadata `gridColumns` value as a width in twelfths, or undefined when unset or invalid.
*
@@ -214,24 +209,27 @@ export function twelfths(gridColumns: number | undefined | null): number | undef
}
/**
* Classes that size an item of a 12-column grid: full width below `lg`, `span` twelfths from `lg`.
* Classes that size a field in a section's 12-column `@container` grid: full width in a narrow
* card, `span` twelfths once the card is wide enough (see `responsive-grid`).
*
* @param span - Width in twelfths.
* @returns The classes.
*/
export function gridSpanClasses(span: number): string {
return `col-span-12 min-w-0 ${LG_SPANS[twelfths(span) ?? 12]}`
return containerSpanClasses(twelfths(span), 'fields')
}
/**
* A widget section's card takes the widget width, otherwise the section width.
* Cards hold tables and field lists, so they only sit side by side when each one is wide enough
* to read (the grid must be an `@container`; see `responsive-grid`).
*
* @param section - Record-view section metadata.
* @param widgets - Widgets available to the viewer.
* @param defaultSpan - Card width when metadata omits it: half-width in an overview, full-width in a selected tab.
* @returns Classes for a full mobile row and the declared desktop span.
* @returns Classes for a full row in a narrow grid and the declared span in a wide one.
*/
export function recordSectionGridSpan(section: QTableSection, widgets?: Record<string, QWidgetMetaData>, defaultSpan = 6): string {
const width = section.widgetName ? widgets?.[section.widgetName]?.gridColumns : section.gridColumns
return gridSpanClasses(twelfths(width) ?? defaultSpan)
return containerSpanClasses(twelfths(width) ?? defaultSpan, 'cards')
}
+63
View File
@@ -0,0 +1,63 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest'
import { containerSpanClasses, DEFAULT_FIELD_SPAN_CLASSES } from './responsive-grid'
/**
* The span an item takes at each container level (base, L1, L2, L3), read back from its classes.
*
* @param classes - The item's classes.
* @param breakpoints - The profile's L1-L3 widths.
* @returns Spans at each level.
*/
function spansAt(classes: string, breakpoints: string[]): number[] {
const spans = [12]
for (const breakpoint of breakpoints) {
const match = new RegExp(`@min-\\[${breakpoint}\\]:col-span-(\\d+)`).exec(classes)
spans.push(match ? Number(match[1]) : spans[spans.length - 1])
}
return spans
}
describe('containerSpanClasses', () => {
it('stacks table cards until the grid can hold two readable cards, then follows the metadata', () => {
const cards = ['66rem', '100rem', '120rem']
expect(spansAt(containerSpanClasses(6, 'cards'), cards)).toEqual([12, 6, 6, 6])
expect(spansAt(containerSpanClasses(3, 'cards'), cards)).toEqual([12, 6, 4, 3])
expect(spansAt(containerSpanClasses(8, 'cards'), cards)).toEqual([12, 12, 8, 8])
expect(spansAt(containerSpanClasses(12, 'cards'), cards)).toEqual([12, 12, 12, 12])
})
it('lets small widgets and fields share a row in narrower grids', () => {
expect(spansAt(containerSpanClasses(3, 'widgets'), ['40rem', '60rem', '76rem'])).toEqual([12, 6, 4, 3])
expect(spansAt(containerSpanClasses(4, 'fields'), ['30rem', '44rem', '56rem'])).toEqual([12, 6, 4, 4])
})
it('treats a missing or invalid span as full width and always allows the item to shrink', () => {
for (const span of [undefined, null, 0, 13, Number.NaN]) {
expect(containerSpanClasses(span, 'cards')).toBe('col-span-12 min-w-0')
}
expect(containerSpanClasses(5.6, 'widgets')).toBe(containerSpanClasses(6, 'widgets'))
})
})
describe('DEFAULT_FIELD_SPAN_CLASSES', () => {
it('pairs read-only fields from a 24rem grid (a tablet panel beside the sidebar) and widens on very wide grids', () => {
expect(spansAt(DEFAULT_FIELD_SPAN_CLASSES, ['24rem', '64rem', '96rem'])).toEqual([12, 6, 4, 3])
})
})
+115
View File
@@ -0,0 +1,115 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file responsive-grid — 12-column grid spans that follow the width of the grid itself
* (CSS container queries), not the viewport.
*
* QQQ metadata sizes cards, widgets and fields in twelfths (`gridColumns`). Applied at a
* viewport breakpoint, a half-width card is half of whatever is left after the sidebar, so two
* table panels sit side by side at 350px each on a laptop. Here an item only takes its declared
* width when the grid is wide enough for that many items of its kind to sit in a row:
*
* | grid width ≥ | cards (tables) | widgets | fields | span |
* |--------------|-----------------|----------|----------|--------------------------|
* | 0 | | | | full width |
* | L1 | 66rem (1056px) | 40rem | 30rem | ≤ 6 → half, else full |
* | L2 | 100rem (1600px) | 60rem | 44rem | at least a third |
* | L3 | 120rem (1920px) | 76rem | 56rem | the declared span |
*
* The grid element must carry {@link GRID_CONTAINER} (`@container`) so its items can query it.
* Every class is spelled out below so Tailwind generates it.
*/
/** Which kind of item a grid holds, which sets how narrow an item may get. */
export type GridItemProfile = 'cards' | 'widgets' | 'fields'
/** Class for a grid element whose items use {@link containerSpanClasses}. */
export const GRID_CONTAINER = '@container'
/** Span classes per profile and declared span (static strings for the Tailwind scanner). */
const SPANS: Record<GridItemProfile, Record<number, string>> = {
cards: {
1: 'col-span-12 @min-[66rem]:col-span-6 @min-[100rem]:col-span-4 @min-[120rem]:col-span-1',
2: 'col-span-12 @min-[66rem]:col-span-6 @min-[100rem]:col-span-4 @min-[120rem]:col-span-2',
3: 'col-span-12 @min-[66rem]:col-span-6 @min-[100rem]:col-span-4 @min-[120rem]:col-span-3',
4: 'col-span-12 @min-[66rem]:col-span-6 @min-[100rem]:col-span-4',
5: 'col-span-12 @min-[66rem]:col-span-6 @min-[100rem]:col-span-5',
6: 'col-span-12 @min-[66rem]:col-span-6',
7: 'col-span-12 @min-[100rem]:col-span-7',
8: 'col-span-12 @min-[100rem]:col-span-8',
9: 'col-span-12 @min-[100rem]:col-span-9',
10: 'col-span-12 @min-[100rem]:col-span-10',
11: 'col-span-12 @min-[100rem]:col-span-11',
12: 'col-span-12',
},
widgets: {
1: 'col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-4 @min-[76rem]:col-span-1',
2: 'col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-4 @min-[76rem]:col-span-2',
3: 'col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-4 @min-[76rem]:col-span-3',
4: 'col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-4',
5: 'col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-5',
6: 'col-span-12 @min-[40rem]:col-span-6',
7: 'col-span-12 @min-[60rem]:col-span-7',
8: 'col-span-12 @min-[60rem]:col-span-8',
9: 'col-span-12 @min-[60rem]:col-span-9',
10: 'col-span-12 @min-[60rem]:col-span-10',
11: 'col-span-12 @min-[60rem]:col-span-11',
12: 'col-span-12',
},
fields: {
1: 'col-span-12 @min-[30rem]:col-span-6 @min-[44rem]:col-span-4 @min-[56rem]:col-span-1',
2: 'col-span-12 @min-[30rem]:col-span-6 @min-[44rem]:col-span-4 @min-[56rem]:col-span-2',
3: 'col-span-12 @min-[30rem]:col-span-6 @min-[44rem]:col-span-4 @min-[56rem]:col-span-3',
4: 'col-span-12 @min-[30rem]:col-span-6 @min-[44rem]:col-span-4',
5: 'col-span-12 @min-[30rem]:col-span-6 @min-[44rem]:col-span-5',
6: 'col-span-12 @min-[30rem]:col-span-6',
7: 'col-span-12 @min-[44rem]:col-span-7',
8: 'col-span-12 @min-[44rem]:col-span-8',
9: 'col-span-12 @min-[44rem]:col-span-9',
10: 'col-span-12 @min-[44rem]:col-span-10',
11: 'col-span-12 @min-[44rem]:col-span-11',
12: 'col-span-12',
},
}
/**
* Default field width when metadata sets none, in a two-column layout of read-only label/value
* pairs: half from 24rem (two ~180px values beside the sidebar on a tablet), a third and then a
* quarter on very wide grids (ultra-wide record tabs); phones stack.
*/
export const DEFAULT_FIELD_SPAN_CLASSES = 'col-span-12 min-w-0 @min-[24rem]:col-span-6 @min-[64rem]:col-span-4 @min-[96rem]:col-span-3'
/**
* A declared `gridColumns` value as a span of 1 to 12.
*
* @param span - Declared twelfths.
* @returns The rounded span, or 12 when unset or invalid.
*/
function normalizedSpan(span: number | undefined | null): number {
return typeof span === 'number' && Number.isFinite(span) && span >= 1 && span <= 12 ? Math.round(span) : 12
}
/**
* Classes that size an item of a 12-column `@container` grid by the grid's own width.
*
* @param span - Declared width in twelfths (`gridColumns`); full width when unset.
* @param profile - What the grid holds.
* @returns The classes (always including `min-w-0`, so wide content cannot stretch the track).
*/
export function containerSpanClasses(span: number | undefined | null, profile: GridItemProfile): string {
return `${SPANS[profile][normalizedSpan(span)]} min-w-0`
}
+268
View File
@@ -0,0 +1,268 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file CMS fixture — a site with child domain and blog lists, shaped like a real content
* app: wide child tables with long text (descriptions, meta tags) and zoned timestamps, so
* record views, embedded child lists, the query grid and the app home can be checked for
* readability at every width.
*/
import type { QAppMetaData, QAppTreeNode, QFieldMetaData, QRecord, QTableMetaData, QWidgetMetaData } from '@/types'
/**
* Field metadata with the defaults the backend sends.
*
* @param name - Field name.
* @param label - Field label.
* @param type - Field type.
* @param overrides - Extra metadata.
* @returns The field.
*/
function field(name: string, label: string, type: QFieldMetaData['type'], overrides: Partial<QFieldMetaData> = {}): QFieldMetaData {
return { name, label, type, isRequired: false, isEditable: true, isHeavy: false, isHidden: false, adornments: [], ...overrides }
}
/**
* A wire timestamp some hours before now, so "today" values exist whenever the mock runs.
*
* @param hours - Hours ago.
* @returns ISO-8601 instant in UTC.
*/
function hoursAgo(hours: number): string {
return new Date(Date.now() - hours * 3_600_000).toISOString().replace(/\.\d{3}Z$/, 'Z')
}
/** Capabilities and permissions every CMS table grants in the mock. */
const tableDefaults = {
exposedJoins: [],
capabilities: ['TABLE_QUERY', 'TABLE_GET', 'TABLE_COUNT', 'TABLE_INSERT', 'TABLE_UPDATE', 'TABLE_DELETE'],
readPermission: true,
insertPermission: true,
editPermission: true,
deletePermission: true,
usesVariants: false,
variantTableLabel: '',
} satisfies Partial<QTableMetaData>
const auditFields = {
createDate: field('createDate', 'Create Date', 'DATE_TIME', { isEditable: false }),
modifyDate: field('modifyDate', 'Modify Date', 'DATE_TIME', { isEditable: false }),
}
export const siteTable: QTableMetaData = {
name: 'site',
label: 'Sites',
isHidden: false,
primaryKeyField: 'id',
...tableDefaults,
fields: {
id: field('id', 'Id', 'INTEGER', { isEditable: false }),
name: field('name', 'Name', 'STRING', { maxLength: 100 }),
shortName: field('shortName', 'Short Name', 'STRING', { maxLength: 50 }),
description: field('description', 'Description', 'STRING', { maxLength: 500 }),
launchDate: field('launchDate', 'Launch Date', 'DATE'),
...auditFields,
},
sections: [
{ name: 'identity', label: 'Site', tier: 'T1', iconName: 'language', fieldNames: ['id', 'name', 'shortName', 'description'], isHidden: false },
{ name: 'domains', label: 'Domains', tier: 'T2', iconName: 'dns', fieldNames: [], widgetName: 'siteDomains', isHidden: false },
{ name: 'blogs', label: 'Blogs', tier: 'T2', iconName: 'folder', fieldNames: [], widgetName: 'siteBlogs', isHidden: false },
{ name: 'dates', label: 'Dates', tier: 'T3', iconName: 'calendar_month', fieldNames: ['launchDate', 'createDate', 'modifyDate'], isHidden: false },
],
}
export const siteDomainTable: QTableMetaData = {
name: 'siteDomain',
label: 'Site Domains',
isHidden: false,
primaryKeyField: 'id',
...tableDefaults,
fields: {
id: field('id', 'Id', 'INTEGER', { isEditable: false }),
siteId: field('siteId', 'Site', 'INTEGER', { possibleValueSourceName: 'site' }),
name: field('name', 'Name', 'STRING', { maxLength: 255 }),
shortName: field('shortName', 'Short Name', 'STRING', { maxLength: 100 }),
fqdn: field('fqdn', 'Fqdn', 'STRING', { maxLength: 255 }),
isPrimary: field('isPrimary', 'Is Primary', 'BOOLEAN'),
description: field('description', 'Description', 'STRING', { maxLength: 500 }),
...auditFields,
},
sections: [
{ name: 'identity', label: 'Domain', tier: 'T1', iconName: 'dns', fieldNames: ['id', 'siteId', 'name', 'shortName', 'fqdn', 'isPrimary'], isHidden: false },
{ name: 'details', label: 'Details', tier: 'T2', iconName: 'info', fieldNames: ['description'], isHidden: false },
{ name: 'dates', label: 'Dates', tier: 'T3', iconName: 'calendar_month', fieldNames: ['createDate', 'modifyDate'], isHidden: false },
],
}
export const siteBlogTable: QTableMetaData = {
name: 'siteBlog',
label: 'Site Blogs',
isHidden: false,
primaryKeyField: 'id',
...tableDefaults,
fields: {
id: field('id', 'Id', 'INTEGER', { isEditable: false }),
siteId: field('siteId', 'Site', 'INTEGER', { possibleValueSourceName: 'site' }),
name: field('name', 'Name', 'STRING', { maxLength: 255 }),
shortName: field('shortName', 'Short Name', 'STRING', { maxLength: 100 }),
slug: field('slug', 'Slug', 'STRING', { maxLength: 100 }),
blogTypeId: field('blogTypeId', 'Blog Type', 'INTEGER', { possibleValueSourceName: 'blogType' }),
description: field('description', 'Description', 'STRING', { maxLength: 1000 }),
metaTitle: field('metaTitle', 'Meta Title', 'STRING', { maxLength: 255 }),
metaDescription: field('metaDescription', 'Meta Description', 'STRING', { maxLength: 1000 }),
metaKeywords: field('metaKeywords', 'Meta Keywords', 'STRING', { maxLength: 1000 }),
publishDate: field('publishDate', 'Publish Date', 'DATE'),
...auditFields,
},
sections: [
{ name: 'identity', label: 'Blog', tier: 'T1', iconName: 'article', fieldNames: ['id', 'siteId', 'name', 'shortName', 'slug', 'blogTypeId'], isHidden: false },
{ name: 'details', label: 'Details', tier: 'T2', iconName: 'info', fieldNames: ['description', 'publishDate'], isHidden: false },
{ name: 'seo', label: 'SEO', tier: 'T2', iconName: 'search', fieldNames: ['metaTitle', 'metaDescription', 'metaKeywords'], isHidden: false },
{ name: 'dates', label: 'Dates', tier: 'T3', iconName: 'calendar_month', fieldNames: ['createDate', 'modifyDate'], isHidden: false },
],
}
export const siteRecords: QRecord[] = [
{
tableName: 'site',
recordLabel: 'Kof22',
values: {
id: 1, name: 'Kof22', shortName: 'KofTwentyTwo Personal Site', description: 'Kof22 Personal Website for James Maes',
launchDate: '2026-07-05', createDate: '2026-07-05T23:33:29Z', modifyDate: hoursAgo(3),
},
displayValues: { id: '1' },
},
]
/**
* A site domain record.
*
* @param id - Key.
* @param name - Host name.
* @param isPrimary - Primary domain flag.
* @param description - Description.
* @param createDate - Created instant.
* @param modifyDate - Modified instant.
* @returns The record.
*/
function domain(id: number, name: string, isPrimary: boolean, description: string, createDate: string, modifyDate: string): QRecord {
return {
tableName: 'siteDomain',
recordLabel: name,
values: { id, siteId: 1, name, shortName: name, fqdn: name, isPrimary, description, createDate, modifyDate },
displayValues: { id: String(id), siteId: 'Kof22', isPrimary: isPrimary ? 'Yes' : 'No' },
}
}
export const siteDomainRecords: QRecord[] = [
domain(1, 'www.kof22.com', true, 'Primary base domain', '2026-07-05T23:33:29Z', '2026-07-05T23:33:29Z'),
domain(2, 'kof22.com', false, 'Naked domain, redirect to www', '2026-07-05T23:33:29Z', '2025-11-02T15:04:00Z'),
domain(3, 'koftwentytwo.com', false, 'Naked domain, redirect to www', hoursAgo(5), hoursAgo(5)),
domain(4, 'www.koftwentytwo.com', false, 'Naked domain, redirect to www', hoursAgo(4.8), hoursAgo(1)),
]
const fieldNotes = 'Notes from the work of building systems, making decisions and understanding what changes along the way.'
const ideas = 'A CTO\'s perspective on how technical ideas become systems, and what those decisions mean for the people building and depending on them.'
export const siteBlogRecords: QRecord[] = [
{
tableName: 'siteBlog',
recordLabel: 'Field Notes',
values: {
id: 1, siteId: 1, name: 'Field Notes', shortName: 'Field Notes', slug: 'quick-bits', blogTypeId: 1, description: fieldNotes,
metaTitle: 'Field Notes - Engineering & Leadership', metaDescription: fieldNotes, metaKeywords: 'engineering, leadership, observations, systems',
publishDate: '2026-07-06', createDate: '2026-07-05T23:33:29Z', modifyDate: '2026-10-10T11:01:39Z',
},
displayValues: { id: '1', siteId: 'Kof22', blogTypeId: 'Site Blog' },
},
{
tableName: 'siteBlog',
recordLabel: 'Ideas Into Systems',
values: {
id: 2, siteId: 1, name: 'Ideas Into Systems', shortName: 'Ideas Into Systems', slug: 'ideas-into-systems', blogTypeId: 1, description: ideas,
metaTitle: 'Ideas Into Systems - A CTO\'s Perspective', metaDescription: ideas, metaKeywords: 'technology, architecture, leadership, engineering, systems',
publishDate: '2025-03-14', createDate: '2026-07-05T23:33:29Z', modifyDate: hoursAgo(2),
},
displayValues: { id: '2', siteId: 'Kof22', blogTypeId: 'Site Blog' },
},
]
/**
* The `ChildRecordListRenderer` payload for a child table of site 1, as the backend sends it
* (the join field is omitted and becomes the new child's default).
*
* @param table - Child table.
* @param records - Child rows.
* @param label - Widget label.
* @returns The payload.
*/
function childList(table: QTableMetaData, records: QRecord[], label: string): Record<string, unknown> {
return {
type: 'childRecordList',
title: label,
label,
childFrontendTableMetaData: table,
queryOutput: { records },
totalRows: records.length,
viewAllLink: `/cms/sites/${table.name}?filter=${encodeURIComponent(JSON.stringify({ criteria: [{ fieldName: 'siteId', operator: 'EQUALS', values: [1] }] }))}`,
defaultValuesForNewChildRecords: { siteId: 1 },
omitFieldNames: ['siteId'],
canAddChildRecord: false,
}
}
/** Widget payloads served for the CMS widgets. */
export const cmsWidgetData: Record<string, () => unknown> = {
siteDomains: () => childList(siteDomainTable, siteDomainRecords, 'Site Domains'),
siteBlogs: () => childList(siteBlogTable, siteBlogRecords, 'Site Blogs'),
cmsRecentBlogs: () => childList(siteBlogTable, siteBlogRecords, 'Recently Changed Blogs'),
cmsSiteCount: () => ({ type: 'statistics', title: 'Sites', value: siteRecords.length, unit: 'sites' }),
cmsDomainCount: () => ({ type: 'statistics', title: 'Domains', value: siteDomainRecords.length, unit: 'domains' }),
cmsBlogCount: () => ({ type: 'statistics', title: 'Blogs', value: siteBlogRecords.length, unit: 'blogs' }),
}
export const cmsWidgets: Record<string, QWidgetMetaData> = {
siteDomains: { name: 'siteDomains', label: 'Domains', type: 'childRecordList', hasPermission: true, showExportButton: true, showReloadButton: true },
siteBlogs: { name: 'siteBlogs', label: 'Blogs', type: 'childRecordList', hasPermission: true, showExportButton: true, showReloadButton: true },
cmsSiteCount: { name: 'cmsSiteCount', label: 'Sites', type: 'statistics', hasPermission: true, gridColumns: 4 },
cmsDomainCount: { name: 'cmsDomainCount', label: 'Domains', type: 'statistics', hasPermission: true, gridColumns: 4 },
cmsBlogCount: { name: 'cmsBlogCount', label: 'Blogs', type: 'statistics', hasPermission: true, gridColumns: 4 },
cmsRecentBlogs: { name: 'cmsRecentBlogs', label: 'Recently Changed Blogs', type: 'childRecordList', hasPermission: true, gridColumns: 12 },
}
export const cmsTreeNode: QAppTreeNode = {
name: 'cms',
label: 'CMS',
type: 'APP',
iconName: 'web',
children: [
{ name: 'site', label: 'Sites', type: 'TABLE', iconName: 'language' },
{ name: 'siteDomain', label: 'Domains', type: 'TABLE', iconName: 'dns' },
{ name: 'siteBlog', label: 'Blogs', type: 'TABLE', iconName: 'article' },
],
}
export const cmsApp: QAppMetaData = {
name: 'cms',
label: 'CMS',
iconName: 'web',
widgets: ['cmsSiteCount', 'cmsDomainCount', 'cmsBlogCount', 'cmsRecentBlogs'],
children: cmsTreeNode.children ?? [],
sections: [
{ name: 'sites', label: 'Sites', icon: { name: 'language' }, tables: ['site', 'siteDomain', 'siteBlog'], processes: [], reports: [] },
],
}
+7 -2
View File
@@ -26,6 +26,7 @@ import type {
QAppMetaData,
QAppTreeNode,
} from '@/types'
import { cmsApp, cmsTreeNode, cmsWidgets, siteBlogTable, siteDomainTable, siteTable } from './cms'
// ─── Field helpers ────────────────────────────────────────────────────────────
@@ -900,8 +901,9 @@ export const qInstance: QInstance = {
apps: {
crm: crmApp,
inventory: inventoryApp,
cms: cmsApp,
},
appTree: [crmTreeNode, inventoryTreeNode],
appTree: [crmTreeNode, inventoryTreeNode, cmsTreeNode],
tables: {
person: personTable,
company: companyTable,
@@ -909,6 +911,9 @@ export const qInstance: QInstance = {
orderLine: orderLineTable,
product: productTable,
supplier: supplierTable,
site: siteTable,
siteDomain: siteDomainTable,
siteBlog: siteBlogTable,
},
processes: {
importPeople: importPeopleProcess,
@@ -918,7 +923,7 @@ export const qInstance: QInstance = {
bulkUpdateOrderStatus: bulkUpdateStatusProcess,
},
reports: {},
widgets: { ...crmWidgets, ...inventoryWidgets },
widgets: { ...crmWidgets, ...inventoryWidgets, ...cmsWidgets },
branding: {
companyName: 'QQQ Demo',
companyUrl: 'https://qqq.example.com',
+13
View File
@@ -30,6 +30,7 @@ import { orderLineRecords } from '../fixtures/records/order-line'
import { productRecords } from '../fixtures/records/product'
import { supplierRecords } from '../fixtures/records/supplier'
import { auditRecords } from '../fixtures/records/audits'
import { siteBlogRecords, siteDomainRecords, siteRecords } from '../fixtures/cms'
const BASE = '/qqq/v1'
@@ -52,6 +53,9 @@ const store: Record<string, QRecord[]> = {
orderLine: deepClone(orderLineRecords),
product: deepClone(productRecords),
supplier: deepClone(supplierRecords),
site: deepClone(siteRecords),
siteDomain: deepClone(siteDomainRecords),
siteBlog: deepClone(siteBlogRecords),
}
// Auto-increment counters per table
@@ -62,6 +66,9 @@ const nextId: Record<string, number> = {
orderLine: 21,
product: 16,
supplier: 9,
site: 2,
siteDomain: 5,
siteBlog: 3,
}
// ─── Filter helpers ───────────────────────────────────────────────────────────
@@ -177,6 +184,9 @@ const primaryKeyFields: Record<string, string> = {
orderLine: 'id',
product: 'id',
supplier: 'id',
site: 'id',
siteDomain: 'id',
siteBlog: 'id',
}
/**
@@ -288,6 +298,9 @@ const tableLabels: Record<string, string> = {
orderLine: 'Order Lines',
product: 'Products',
supplier: 'Suppliers',
site: 'Sites',
siteDomain: 'Site Domains',
siteBlog: 'Site Blogs',
}
// ─── Handlers ────────────────────────────────────────────────────────────────
+2 -1
View File
@@ -37,6 +37,7 @@ import {
invValueTrend,
invLowStockItems,
} from '../fixtures/widgets/inventory'
import { cmsWidgetData } from '../fixtures/cms'
const widgetData: Record<string, unknown> = {
// CRM widgets
@@ -59,7 +60,7 @@ export const widgetHandlers = [
// GET /widget/:widgetName
http.post('/qqq/v1/widget/:widgetName', ({ params }) => {
const { widgetName } = params as { widgetName: string }
const data = widgetData[widgetName]
const data = widgetData[widgetName] ?? cmsWidgetData[widgetName]?.()
if (!data) {
return HttpResponse.json(
+1 -1
View File
@@ -361,7 +361,7 @@
"fixture": "fieldLab; browser zone America/New_York",
"scenarios": [
"typed local time stored as the UTC instant",
"view shows yyyy-MM-dd hh:mm:ss AM EDT",
"view shows the compact date (Jul 4, 2024) with the full yyyy-MM-dd hh:mm:ss AM EDT timestamp on hover",
"edit form shows the local time"
],
"negative": [
+2 -1
View File
@@ -437,7 +437,8 @@
],
"fixture": "tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accWidgetHostJoinChild (maxRows 2)",
"scenarios": [
"rows link to child records, Showing 2 of 3, View All filter link"
"rows link to child records, Showing 2 of 3, View All filter link",
"the key column is behind the more-columns toggle while the name identifies the row"
],
"negative": [
"host with no children shows empty state"
@@ -59,7 +59,7 @@ for (const mode of ['light', 'dark']) {
const email = panel.locator('[data-qqq-id="record-field-email"]')
const birthDate = panel.locator('[data-qqq-id="record-field-birthDate"]')
await expect(email).toContainText('avery@example.invalid')
await expect(birthDate).toContainText('1990-01-15')
await expect(birthDate).toContainText('Jan 15, 1990')
await expect(email.locator('dd')).toHaveCSS('line-height', '20px')
const emailBox = (await email.boundingBox())!
const birthDateBox = (await birthDate.boundingBox())!
@@ -352,7 +352,8 @@ test.describe('record developer view: associated scripts', () => {
await expect(lines.locator('tbody tr td:nth-child(2)')).toHaveText(expectedLines)
await expectNoSidewaysScroll(page)
await expectTouchTargets(card(page))
expect(await lines.locator('tbody tr td:nth-child(1)').first().innerText()).toMatch(DATE_TIME)
// the log line's time is compact on screen with the full zoned timestamp on hover
await expect(lines.locator('tbody tr td:nth-child(1) time').first()).toHaveAttribute('title', DATE_TIME)
})
test('[REC-054] a failing test run shows the error the backend returns @mobile', async ({ page, backend, diagnostics }) => {
@@ -387,7 +388,9 @@ test.describe('record developer view: associated scripts', () => {
{ id: '102', start_timestamp: '2026-03-04 09:15:00', run_time_millis: '1500', had_error: 'TRUE', input: '{"name":""}', output: null, error: 'Name is required' },
{ id: '101', start_timestamp: '2026-03-03 08:00:00', run_time_millis: '42', had_error: 'FALSE', input: '{"name":"Ada"}', output: 'Hello, Ada!', error: null },
])
const shownStart: Record<string, string> = { '2026-03-04 09:15:00': '2026-03-04 04:15:00 AM EST', '2026-03-03 08:00:00': '2026-03-03 03:00:00 AM EST' }
// compact on screen (with the year once it is not the current one), the full timestamp on hover
const shownStart: Record<string, RegExp> = { '2026-03-04 09:15:00': /^Mar 4, (4:15 AM|2026)$/, '2026-03-03 08:00:00': /^Mar 3, (3:00 AM|2026)$/ }
const fullStart: Record<string, string> = { '2026-03-04 09:15:00': '2026-03-04 04:15:00 AM EST', '2026-03-03 08:00:00': '2026-03-03 03:00:00 AM EST' }
expect(await sqlCount(backend, 'select count(*) as n from script_log where script_revision_id = 101')).toBe(0)
await openScriptLab(page, 1)
@@ -402,6 +405,7 @@ test.describe('record developer view: associated scripts', () => {
expect(lines.length).toBeGreaterThan(0)
const row = rows.nth(index)
await expect(row).toHaveAttribute('data-qqq-id', `script-log-${log.id}`)
await expect(row.locator('td').first().locator('time')).toHaveAttribute('title', fullStart[log.start_timestamp!])
await expect(row.locator('td')).toHaveText([
shownStart[log.start_timestamp!],
log.run_time_millis!,
@@ -127,7 +127,8 @@ test('[REC-017] DATE and TIME editors round-trip values and clearing @mobile', a
const id = recordIdFromUrl(page, 'fieldLab')
expect(await sqlOne(backend, `select date_value, time_value from field_lab where id = ${id}`)).toEqual({ date_value: '2024-02-29', time_value: '13:45:30' })
await expandOnPhone(page, 'Field Types')
await expect(fieldValue(page, 'dateValue')).toHaveText('2024-02-29')
await expect(fieldValue(page, 'dateValue')).toHaveText('Feb 29, 2024')
await expect(fieldValue(page, 'dateValue')).toHaveAttribute('dateTime', '2024-02-29')
await expect(fieldValue(page, 'timeValue')).toHaveText('1:45:30 PM')
await openForm(page, `/app/fieldLab/${id}/edit`, 'Edit Field Lab')
@@ -155,7 +156,9 @@ test('[REC-018] DATE_TIME is entered and shown in the viewer zone and stored in
expect(multipartFields(writes.find((request) => request.method() === 'POST')!).dateTimeValue).toBe('2024-07-04T13:15:00Z')
expect((await sqlOne(backend, `select date_time_value from field_lab where id = ${id}`)).date_time_value).toBe('2024-07-04 13:15:00')
await expandOnPhone(page, 'Field Types')
await expect(fieldValue(page, 'dateTimeValue')).toHaveText('2024-07-04 09:15:00 AM EDT')
// compact on screen, the full zoned timestamp on hover
await expect(fieldValue(page, 'dateTimeValue')).toHaveText('Jul 4, 2024')
await expect(fieldValue(page, 'dateTimeValue')).toHaveAttribute('title', '2024-07-04 09:15:00 AM EDT')
await openForm(page, `/app/fieldLab/${id}/edit`, 'Edit Field Lab')
await expect(control(page, 'dateTimeValue')).toHaveValue('2024-07-04T09:15')
@@ -173,7 +176,8 @@ test('[REC-018] DATE_TIME is entered and shown in the viewer zone and stored in
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByRole('heading', { level: 1, name: 'Instant Renamed' })).toBeVisible()
await expandOnPhone(page, 'Field Types')
await expect(fieldValue(page, 'dateTimeValue')).toHaveText('2024-01-15 09:15:00 AM EST')
await expect(fieldValue(page, 'dateTimeValue')).toHaveText('Jan 15, 2024')
await expect(fieldValue(page, 'dateTimeValue')).toHaveAttribute('title', '2024-01-15 09:15:00 AM EST')
expect((await sqlOne(backend, `select date_time_value from field_lab where id = ${id}`)).date_time_value).toBe('2024-01-15 14:15:00')
})
@@ -209,16 +213,21 @@ test('[REC-019] fixed-zone and per-record-zone date-times display in their decla
await openRecord(page, 'fieldLab', newYork, 'Zone NY')
await showSection(page, 'Time Zones')
await expect(fieldValue(page, 'fixedZoneDateTime')).toHaveText('2024-03-10 03:30:00 AM CDT')
await expect(fieldValue(page, 'recordZoneDateTime')).toHaveText('2024-03-10 04:30:00 AM EDT')
// a zone other than the viewer's (New York) keeps its time and zone; the viewer's own zone shows compactly
await expect(fieldValue(page, 'fixedZoneDateTime')).toHaveText('Mar 10, 2024, 3:30 AM CDT')
await expect(fieldValue(page, 'fixedZoneDateTime')).toHaveAttribute('title', /^2024-03-10 03:30:00 AM CDT\nYour time: 2024-03-10 04:30:00 AM EDT$/)
await expect(fieldValue(page, 'recordZoneDateTime')).toHaveText('Mar 10, 2024')
await expect(fieldValue(page, 'recordZoneDateTime')).toHaveAttribute('title', '2024-03-10 04:30:00 AM EDT')
await openRecord(page, 'fieldLab', beforeDst, 'Zone Before DST')
await expandOnPhone(page, 'Time Zones')
await expect(fieldValue(page, 'recordZoneDateTime')).toHaveText('2024-03-10 01:30:00 AM EST')
await expect(fieldValue(page, 'recordZoneDateTime')).toHaveText('Mar 10, 2024')
await expect(fieldValue(page, 'recordZoneDateTime')).toHaveAttribute('title', '2024-03-10 01:30:00 AM EST')
await openRecord(page, 'fieldLab', invalid, 'Zone Invalid')
await expandOnPhone(page, 'Time Zones')
await expect(fieldValue(page, 'recordZoneDateTime')).toHaveText('2024-11-03 06:30:00 AM UTC')
await expect(fieldValue(page, 'recordZoneDateTime')).toHaveText('Nov 3, 2024, 6:30 AM UTC')
await expect(fieldValue(page, 'recordZoneDateTime')).toHaveAttribute('title', /^2024-11-03 06:30:00 AM UTC\n/)
expect((await sqlOne(backend, `select record_zone_date_time from field_lab where id = ${invalid}`)).record_zone_date_time).toBe('2024-11-03 06:30:00')
})
@@ -105,7 +105,9 @@ test('[REC-042] audit history lists who changed what, newest first @mobile', asy
.toHaveText(['Changed Hint from "Check twice" to "Check three times"', 'Removed "ALPHA-01" from Short Code'])
await expect(entries.nth(0).locator('[data-qqq-id="audit-entry-user"]')).toHaveText('Alice (sample)')
await expect(entries.nth(0).locator('[data-qqq-id="audit-entry-message"]')).toHaveText('Record was Edited')
await expect(entries.nth(0).locator('[data-qqq-id="audit-entry-timestamp"]')).toHaveText(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [AP]M E[DS]T$/)
// today's entry shows its time; the full zoned timestamp is the tooltip
await expect(entries.nth(0).locator('[data-qqq-id="audit-entry-timestamp"]')).toHaveText(/^\d{1,2}:\d{2} [AP]M$/)
await expect(entries.nth(0).locator('[data-qqq-id="audit-entry-timestamp"]')).toHaveAttribute('title', /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [AP]M E[DS]T$/)
await expect(dialog.locator('[data-qqq-id="audit-date-heading"]')).toHaveCount(1)
await expect(dialog.locator('[data-qqq-id="audit-date-heading"]')).toContainText(/\(Today\)$/)
await dialog.getByRole('button', { name: 'Sort by time ascending (oldest to newest)' }).click()
+3 -1
View File
@@ -22,7 +22,9 @@ test('[REC-001] record view shows the label, tiered sections and formatted value
await expect(page.getByRole('heading', { level: 3, name: 'Basic Info' })).toBeVisible()
await expect(page.getByRole('heading', { level: 3, name: 'Employment Info' })).toBeVisible()
await expect(fieldValue(page, 'email')).toHaveText(person.email!)
await expect(fieldValue(page, 'birthDate')).toHaveText(person.birth_date!)
// a DATE shows compactly (`Jan 15, 1990`, never a time) and keeps the exact calendar day as its machine value
await expect(fieldValue(page, 'birthDate')).toHaveAttribute('dateTime', person.birth_date!)
await expect(fieldValue(page, 'birthDate')).toHaveText(/^[A-Z][a-z]{2} \d{1,2}(, \d{4})?$/)
expect(person.annual_salary).toBe('75003.50')
await expect(fieldValue(page, 'annualSalary')).toHaveText('$75,003.50')
expect(person.days_worked).toBe('1001')
@@ -58,6 +58,12 @@ test('[WID-024] child record list without an association lists joined records wi
await openHost(page, 1)
await expectLoaded(page, 'accWidgetHostJoinChild')
const list = byId(page, 'widget-childRecordList-accWidgetHostJoinChild')
// the name identifies each row, so the key stays one click away instead of taking a column
await expect(list.getByRole('columnheader')).toHaveText(['Name'])
for (const child of children.slice(0, 2)) {
await expect(byId(page, `child-record-row-accWidgetHostJoinChild-${child.id}`)).toHaveText(child.name)
}
await byId(page, 'child-record-columns-toggle-accWidgetHostJoinChild').click()
await expect(list.getByRole('columnheader')).toHaveText(['Id', 'Name'])
for (const child of children.slice(0, 2)) {
await expect(byId(page, `child-record-row-accWidgetHostJoinChild-${child.id}`)).toHaveText(`${child.id}${child.name}`)