|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * objectstack#26344 — a related list's `date` column reads in ONE face, the |
| 11 | + * absolute short date, when its field declares no `format`. |
| 12 | + * |
| 13 | + * The record page's related lists draw their own table cells (`makeCell` in |
| 14 | + * `RelatedList.tsx`), apart from `ObjectGrid`'s. objectstack#25884 gave |
| 15 | + * `CellRendererProps` its `inGridColumn` flag and passed it on every grid |
| 16 | + * column cell; these cells did not pass it, so a related list's date column |
| 17 | + * still read "Yesterday · 3 days ago · Sep 20, 2025": the `'relative'` default |
| 18 | + * is relative only inside a ±7-day window and absolute outside it. The ruling |
| 19 | + * on #25884 holds here too: absolute in a sortable column, relative at most as |
| 20 | + * a tooltip. |
| 21 | + * |
| 22 | + * Every column source of the real list is driven (A object `columns`, B string |
| 23 | + * `columns`, C the object's declared `highlightFields`, D the field walk), so |
| 24 | + * a cell built down any one of them must say it sits in a column. |
| 25 | + * |
| 26 | + * CONTROLS: a field that DECLARES `format: 'relative'` keeps the relative face |
| 27 | + * in the same list, and a declared deadline keeps its red, its overdue wording |
| 28 | + * moving to the cell's tooltip. |
| 29 | + * |
| 30 | + * The real data-table renders (`SchemaRenderer` is NOT mocked) and the |
| 31 | + * viewport is desktop: a phone-width list swaps the table for a gallery that |
| 32 | + * draws none of these cells. |
| 33 | + */ |
| 34 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 35 | +import { cleanup, render, waitFor } from '@testing-library/react'; |
| 36 | +import '@testing-library/jest-dom'; |
| 37 | +import * as React from 'react'; |
| 38 | + |
| 39 | +// The real data-table (and its cell renderers) must be registered: this pin |
| 40 | +// reads what they DRAW. |
| 41 | +import '@object-ui/components'; |
| 42 | +import { formatDate } from '@object-ui/fields'; |
| 43 | +import { RelatedList } from '../RelatedList'; |
| 44 | + |
| 45 | +beforeEach(() => { |
| 46 | + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); |
| 47 | +}); |
| 48 | +afterEach(cleanup); |
| 49 | + |
| 50 | +/** A calendar day `offset` days from today, as a `date` field stores it. */ |
| 51 | +function storedDay(offset: number): string { |
| 52 | + const d = new Date(); |
| 53 | + d.setDate(d.getDate() + offset); |
| 54 | + const pad = (n: number) => String(n).padStart(2, '0'); |
| 55 | + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; |
| 56 | +} |
| 57 | + |
| 58 | +// Four days inside the relative window and two outside it, one of them in a |
| 59 | +// past year, so every branch of the old default is read in one column. |
| 60 | +const OFFSETS = [-1, -3, -6, 2, -40, -400]; |
| 61 | + |
| 62 | +const FIELDS = { |
| 63 | + title: { type: 'text', label: 'Title' }, |
| 64 | + quoted_on: { type: 'date', label: 'Quoted on' }, |
| 65 | + expires_on: { type: 'date', label: 'Expires on', dueLike: true }, |
| 66 | + followed_up: { type: 'date', label: 'Followed up', format: 'relative' }, |
| 67 | +}; |
| 68 | + |
| 69 | +const ROWS = OFFSETS.map((offset, i) => ({ |
| 70 | + id: `q${i}`, |
| 71 | + title: `Quote ${i}`, |
| 72 | + account: 'a1', |
| 73 | + quoted_on: storedDay(offset), |
| 74 | + expires_on: storedDay(offset), |
| 75 | + followed_up: storedDay(offset), |
| 76 | +})); |
| 77 | + |
| 78 | +const NAMES = ['title', 'quoted_on', 'expires_on', 'followed_up']; |
| 79 | + |
| 80 | +function makeDataSource(schemaExtra: Record<string, unknown> = {}) { |
| 81 | + return { |
| 82 | + getObjectSchema: vi.fn(async () => ({ name: 'quote', fields: FIELDS, ...schemaExtra })), |
| 83 | + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), |
| 84 | + }; |
| 85 | +} |
| 86 | + |
| 87 | +async function mountList(columns: unknown[] | undefined, schemaExtra: Record<string, unknown> = {}) { |
| 88 | + const { container } = render( |
| 89 | + <RelatedList |
| 90 | + title="Quotes" |
| 91 | + type="table" |
| 92 | + api="quote" |
| 93 | + objectName="quote" |
| 94 | + referenceField="account" |
| 95 | + parentId="a1" |
| 96 | + dataSource={makeDataSource(schemaExtra) as any} |
| 97 | + {...(columns ? { columns: columns as any } : {})} |
| 98 | + />, |
| 99 | + ); |
| 100 | + await waitFor(() => expect(container.querySelector('table')).not.toBeNull()); |
| 101 | + await waitFor(() => expect(container.textContent).toContain('Quote 5')); |
| 102 | + return container; |
| 103 | +} |
| 104 | + |
| 105 | +/** The column's cell for each row, top to bottom, keyed by the row's title. */ |
| 106 | +function columnCells(container: HTMLElement, label: string): Map<string, HTMLElement> { |
| 107 | + const headers = [...container.querySelectorAll('thead th')].map((th) => th.textContent ?? ''); |
| 108 | + const index = headers.findIndex((text) => text.includes(label)); |
| 109 | + if (index < 0) throw new Error(`no column headed ${label}; headers: ${JSON.stringify(headers)}`); |
| 110 | + const cells = new Map<string, HTMLElement>(); |
| 111 | + for (const tr of container.querySelectorAll('tbody tr')) { |
| 112 | + const tds = tr.querySelectorAll('td'); |
| 113 | + const title = [...tds].map((td) => td.textContent ?? '').find((text) => /^Quote \d$/.test(text)); |
| 114 | + if (title && tds.length > index) cells.set(title, tds[index] as HTMLElement); |
| 115 | + } |
| 116 | + return cells; |
| 117 | +} |
| 118 | + |
| 119 | +// Every phrase the relative face writes in `en`, inside its window. |
| 120 | +const RELATIVE_WORDING = /ago|yesterday|today|tomorrow|^in \d|overdue/i; |
| 121 | + |
| 122 | +function expectOneAbsoluteFace(container: HTMLElement, label: string, field: 'quoted_on' | 'expires_on') { |
| 123 | + const cells = columnCells(container, label); |
| 124 | + expect(cells.size, `every row reached the ${label} column`).toBe(ROWS.length); |
| 125 | + ROWS.forEach((row, i) => { |
| 126 | + const text = cells.get(`Quote ${i}`)?.textContent ?? ''; |
| 127 | + expect(text).not.toMatch(RELATIVE_WORDING); |
| 128 | + expect(text).toBe(formatDate(row[field], undefined, { locale: 'en-US' })); |
| 129 | + }); |
| 130 | +} |
| 131 | + |
| 132 | +function expectListFaces(container: HTMLElement) { |
| 133 | + // The defect: one face per column, whatever the distance from today. |
| 134 | + expectOneAbsoluteFace(container, 'Quoted on', 'quoted_on'); |
| 135 | + expectOneAbsoluteFace(container, 'Expires on', 'expires_on'); |
| 136 | + |
| 137 | + // The deadline keeps its red on the past rows, and the overdue wording the |
| 138 | + // cell no longer paints is its tooltip. |
| 139 | + const expires = columnCells(container, 'Expires on'); |
| 140 | + const threeDaysPast = expires.get('Quote 1')!.querySelector('span.tabular-nums')!; |
| 141 | + expect(threeDaysPast.className).toContain('text-red-600'); |
| 142 | + expect(threeDaysPast.getAttribute('title')).toBe('Overdue 3d'); |
| 143 | + const inTwoDays = expires.get('Quote 3')!.querySelector('span.tabular-nums')!; |
| 144 | + expect(inTwoDays.className).not.toContain('text-red-600'); |
| 145 | + expect(inTwoDays.getAttribute('title')).toBe('In 2 days'); |
| 146 | + |
| 147 | + // An undeclared date's relative phrase is its tooltip too. |
| 148 | + const yesterday = columnCells(container, 'Quoted on').get('Quote 0')!.querySelector('span.tabular-nums')!; |
| 149 | + expect(yesterday.getAttribute('title')).toBe('Yesterday'); |
| 150 | + |
| 151 | + // CONTROL: a declared `format: 'relative'` keeps the relative face. |
| 152 | + const followed = columnCells(container, 'Followed up'); |
| 153 | + expect(followed.get('Quote 0')?.textContent).toBe('Yesterday'); |
| 154 | + expect(followed.get('Quote 1')?.textContent).toBe('3 days ago'); |
| 155 | +} |
| 156 | + |
| 157 | +describe('objectstack#26344 — a related list date column reads in one absolute face', () => { |
| 158 | + it('A: object `columns`', async () => { |
| 159 | + // Spec-spelled `{ field, label }`: an object column with no display text is |
| 160 | + // left headerless on purpose (`RelatedList.columnHeaderLabel`). |
| 161 | + const columns = NAMES.map((field) => ({ field, label: FIELDS[field as keyof typeof FIELDS].label })); |
| 162 | + expectListFaces(await mountList(columns)); |
| 163 | + }); |
| 164 | + |
| 165 | + it('B: string `columns`', async () => { |
| 166 | + expectListFaces(await mountList(NAMES)); |
| 167 | + }); |
| 168 | + |
| 169 | + it("C: the object's declared `highlightFields`", async () => { |
| 170 | + expectListFaces(await mountList(undefined, { highlightFields: NAMES })); |
| 171 | + }); |
| 172 | + |
| 173 | + it('D: the field walk (no `columns`, no `highlightFields`)', async () => { |
| 174 | + expectListFaces(await mountList(undefined)); |
| 175 | + }); |
| 176 | +}); |
0 commit comments