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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/10375-comment-and-inbox-dates-display-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/collaboration': patch
'@object-ui/console': patch
---

Two more dates now follow the display locale, not the UI language (objectui#10375).

- `CommentThread` (`@object-ui/collaboration`): a comment seven or more days old shows an absolute
date, and that date now takes its locale from `useDisplayLocale()`. It used to format with the UI
language, so a regional display locale was ignored: an English UI with a `de-CH` display locale
read `3/4/2020` where it now reads `4.3.2020`. With no display locale declared, and with no
provider mounted at all, the date renders as before, because the display locale then falls back
to the UI language. A malformed display locale still falls back to the runtime's own date format,
never to the raw ISO string.
- The approvals inbox (`@object-ui/console`): once a submitted, completed or history timestamp is
30 days old (rounded to the nearest day), the inbox shows a date instead of "Nd ago", and that
date now takes the page's display locale in the same way.
- The relative wording ("just now", "5m ago", "3d ago") is unchanged on both faces and stays in the
UI language.
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The approvals inbox's past-30-days date reads the DISPLAY locale, never the
* UI language (objectui#10375).
*
* `formatRelative` answers in words up to 30 days ("3d ago") and with a date
* past that. The date was `new Date(s).toLocaleDateString(language)`, with
* `language` from `useObjectTranslation()`, so a regional display locale
* (`de-CH` under an English UI) never reached it. It now takes the page's
* `useDisplayLocale()` tag.
*
* The real `I18nProvider` runs with an ENGLISH UI in every case, and the
* display locale is declared through `LocalizationProvider`, so the display
* locale is the only thing that differs between the `de-CH` pin and the
* `en-US` control. A page that still passed `language` renders the English
* form under `de-CH` and goes red. `consoleFaces.displayLocale-9909.test.tsx`
* could not see this: it stubs `useObjectTranslation` to `'en'` and declares
* `en` as its control locale, so its two readings of this cell were equal.
*
* Each row is read from its queue cell, found by the cell's `title` tooltip:
* that is `formatDate` in the display locale, a face this card does not touch.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions';
import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
import { isMachineLocale, recordLocaleArgumentsAsync } from '@object-ui/test-support';

/** Noon UTC, so the calendar date is the same in every zone (the suite pins `TZ=UTC`). */
const { OLD, RECENT, approvalsApiStub, ADAPTER, AUTH } = vi.hoisted(() => {
const OLD = '2020-03-04T12:00:00.000Z';
// Three days before the page's clock, which it reads at mount: squarely in
// the "Nd ago" bucket, far from both of its edges.
const RECENT = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString();
const row = (id: string, submittedAt: string): Record<string, unknown> => ({
id,
process_name: 'purchase_approval',
process_label: 'Purchase Approval',
object_name: 'showcase_purchase',
object_label: 'Purchase',
record_id: `po_${id}`,
record_title: `PO ${id}`,
status: 'pending',
pending_approvers: ['u_1'],
submitter_id: 'u_2',
submitter_name: 'Sam Submitter',
submitted_at: submittedAt,
});
const ROWS = [row('old', OLD), row('recent', RECENT)];
const approvalsApiStub = {
listRequests: async () => ({ data: ROWS, total: ROWS.length }),
getRequest: async () => ({ data: ROWS[0] }),
listActions: async () => ({ data: [] }),
approve: async () => ({ data: ROWS[0], finalized: true }),
reject: async () => ({ data: ROWS[0], finalized: true }),
};
const ADAPTER = {
find: async () => ({ data: [{ id: 'po_old' }, { id: 'po_recent' }] }),
getObjectSchema: async () => ({ fields: {} }),
};
const AUTH = { user: { id: 'u_1', email: 'approver@example.com' } };
return { OLD, RECENT, approvalsApiStub, ADAPTER, AUTH };
});

vi.mock('@object-ui/auth', async (importOriginal) => {
const authFetch = async () => new Response('{}', { status: 200 });
return {
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => AUTH,
createAuthenticatedFetch: () => authFetch,
TokenStorage: { get: () => null },
};
});

vi.mock('@object-ui/app-shell', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => ADAPTER,
useMetadata: () => ({ objects: [] }),
DeclaredActionsBar: () => null,
isViaOverrideRow: () => false,
}));

vi.mock('../../services/approvalsApi', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
approvalsApi: approvalsApiStub,
}));

import { ApprovalsInboxPage } from './ApprovalsInboxPage';

afterEach(() => cleanup());

function permissions(): MePermissionsResponse {
return {
authenticated: true,
userId: 'u_1',
tenantId: 't_1',
roles: [],
permissionSets: [],
objects: {},
fields: {},
systemPermissions: ['setup.access'],
};
}

function renderInbox(locale: string) {
return render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
<LocalizationProvider value={{ locale }}>
<MePermissionsProvider initialPermissions={permissions()}>
<MemoryRouter initialEntries={['/apps/app/system/approvals']}>
<Routes>
<Route path="/apps/:appName/system/approvals" element={<ApprovalsInboxPage />} />
</Routes>
</MemoryRouter>
</MePermissionsProvider>
</LocalizationProvider>
</I18nProvider>,
);
}

/** The queue cell for a row submitted at `submittedAt`, under `locale` as the display locale. */
async function queueCell(locale: string, submittedAt: string): Promise<string> {
renderInbox(locale);
await screen.findAllByText('PO old');
const title = new Date(submittedAt).toLocaleString(locale);
const cells = [...document.querySelectorAll('td[title]')].filter((el) => el.getAttribute('title') === title);
expect(cells, `no queue cell titled ${title}`).toHaveLength(1);
const text = (cells[0].textContent ?? '').trim();
cleanup();
return text;
}

describe('ApprovalsInboxPage — the past-30-days date follows the display locale (objectui#10375)', () => {
it('formats as de-CH under an English UI with a de-CH display locale', async () => {
const text = await queueCell('de-CH', OLD);
expect(text, `got: ${text}`).toBe('4.3.2020');
expect(text).toBe(new Date(OLD).toLocaleDateString('de-CH'));
});

it('control: formats as en-US under an en-US display locale', async () => {
const text = await queueCell('en-US', OLD);
expect(text, `got: ${text}`).toBe('3/4/2020');
expect(text).toBe(new Date(OLD).toLocaleDateString('en-US'));
});

/** ⭐ THE PIN: no runner locale can make both readings equal. */
it('is a reading of the session, not of the machine', async () => {
expect(await queueCell('de-CH', OLD)).not.toBe(await queueCell('en-US', OLD));
});

it('every date the page formats receives the declared tag, never the machine locale', async () => {
const calls = await recordLocaleArgumentsAsync(async () => {
await queueCell('de-CH', OLD);
});
const dates = calls.filter((c) => c.api === 'Date.prototype.toLocaleDateString');
expect(dates.length, `saw: ${JSON.stringify(calls).slice(0, 2000)}`).toBeGreaterThan(0);
expect(dates.every((c) => c.locale === 'de-CH'), `saw: ${JSON.stringify(dates)}`).toBe(true);
expect(calls.filter(isMachineLocale), 'these call sites formatted in the machine locale').toEqual([]);
});

/**
* The buckets up to 30 days are WORDS, so they stay on `tr` and the UI
* language: a three-day-old request under an English UI reads the English
* bucket even with a `de-CH` display locale — not a date, and not German.
*/
it('leaves the relative buckets on the UI language', async () => {
expect(await queueCell('de-CH', RECENT)).toBe('3d ago');
});
});
9 changes: 6 additions & 3 deletions apps/console/src/pages/system/ApprovalsInboxPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@ function InlineActions({
}

export function ApprovalsInboxPage() {
const { t, language } = useObjectTranslation();
const { t } = useObjectTranslation();
// Every date and number face on this page is formatted in the display
// locale; the module helpers above take it as a required argument.
const displayLocale = useDisplayLocale();
Expand Down Expand Up @@ -738,8 +738,11 @@ export function ApprovalsInboxPage() {
if (hr < 24) return tr('hoursAgo', '{{count}}h ago', { count: hr });
const day = Math.round(hr / 24);
if (day < 30) return tr('daysAgo', '{{count}}d ago', { count: day });
try { return new Date(s).toLocaleDateString(language); } catch { return s; }
}, [tr, language, now]);
// Past 30 days the face is a DATE, not words, so it takes the display
// locale, never the UI language `tr` speaks: a regional locale (`de-CH`
// under an English UI) must reach it (objectui#10375).
try { return new Date(s).toLocaleDateString(displayLocale); } catch { return s; }
}, [tr, displayLocale, now]);

const statusLabel = useCallback((status: string): string => {
switch (status) {
Expand Down
49 changes: 33 additions & 16 deletions packages/collaboration/src/CommentThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useDisplayLocale } from '@object-ui/i18n';
import {
useCollaborationTranslation,
type CollaborationTranslate,
Expand Down Expand Up @@ -52,14 +53,22 @@ export interface CommentThreadProps {
}

/**
* Absolute date for the >= 7d bucket, in the session language (objectui#3441).
* Absolute date for the >= 7d bucket, in the session's DISPLAY locale — the tag
* `useDisplayLocale()` resolves, which the component hands in (objectui#10375).
*
* objectui#3441 first localized this branch with the session LANGUAGE, before
* the display-locale contract existed. That tag is the UI language, so a
* regional display locale never reached the date: an English UI with a `de-CH`
* display locale read `3/4/2020`, not `4.3.2020`. The display locale falls back
* to the UI language when the session declares no regional locale, so such a
* session renders exactly as it did under objectui#3441.
*
* Has its OWN try/catch, deliberately not sharing `formatTimestamp`'s. The two
* catches recover from different things and must recover differently:
*
* - `formatTimestamp`'s outer catch is for an input it cannot make sense of,
* and its only honest fallback is to echo the raw `iso` back.
* - a throw from here says nothing about the *date* — it says the LANGUAGE TAG
* - a throw from here says nothing about the *date* — it says the LOCALE TAG
* is malformed. `Date.prototype.toLocaleDateString(tag)` runs the tag through
* `CanonicalizeLocaleList`, which raises `RangeError` for anything not
* structurally well-formed per BCP 47 (`'en_US'`, `''`, `'zh CN'`). A
Expand All @@ -71,37 +80,40 @@ export interface CommentThreadProps {
* raw `2026-08-01T09:30:00.000Z`, i.e. WORSE than the un-localized date it
* replaced. Falling back to the no-argument call restores exactly the previous
* behaviour (the runtime's own locale) for that path, so the worst case of
* following the session language is the status quo, never a regression.
* following the display locale is the status quo, never a regression.
*
* No date library, and no month/weekday copy in the locale packs: `Intl` is
* already in the runtime and owns the per-locale ordering and separators.
*/
function formatAbsoluteDate(date: Date, language: string): string {
function formatAbsoluteDate(date: Date, locale: string): string {
try {
return date.toLocaleDateString(language);
return date.toLocaleDateString(locale);
} catch {
return date.toLocaleDateString();
}
}

/**
* Relative age of a comment, in the session language.
* Relative age of a comment: the words in the session language, the absolute
* date in the display locale.
*
* `t` and `language` are threaded in as parameters rather than read from a
* `t` and `locale` are threaded in as parameters rather than read from a
* hook: this runs once per rendered comment from inside `renderComment`, and
* the buckets are unchanged — only the words moved into the locale packs.
* Counts are interpolated as STRINGS on purpose, so i18next skips its own
* plural resolution (`needsPluralHandling` is false for a string `count`) and
* cannot silently start looking for `_one`/`_other` variants this repo does not
* ship.
*
* The >= 7d bucket follows the session language too (objectui#3441) — a `zh`
* session used to read "6 天前" for a six-day-old comment and `8/1/2026` for an
* eight-day-old one, because that branch called `toLocaleDateString()` with no
* argument and got the *runtime's* locale. See {@link formatAbsoluteDate} for
* why the tag gets its own guard instead of being handed straight in.
* The >= 7d bucket is a DATE, not words, so it takes the display locale
* (objectui#10375), not the language `t` speaks. It was first localized under
* objectui#3441 — a `zh` session used to read "6 天前" for a six-day-old
* comment and `8/1/2026` for an eight-day-old one, because that branch called
* `toLocaleDateString()` with no argument and got the *runtime's* locale. See
* {@link formatAbsoluteDate} for why the tag gets its own guard instead of
* being handed straight in.
*/
function formatTimestamp(iso: string, t: CollaborationTranslate, language: string): string {
function formatTimestamp(iso: string, t: CollaborationTranslate, locale: string): string {
try {
const date = new Date(iso);
const now = new Date();
Expand All @@ -113,7 +125,7 @@ function formatTimestamp(iso: string, t: CollaborationTranslate, language: strin
if (hours < 24) return t('collaboration.hoursAgo', { count: String(hours) });
const days = Math.floor(hours / 24);
if (days < 7) return t('collaboration.daysAgo', { count: String(days) });
return formatAbsoluteDate(date, language);
return formatAbsoluteDate(date, locale);
} catch {
return iso;
}
Expand Down Expand Up @@ -401,7 +413,12 @@ export function CommentThread({
const [mentionIndex, setMentionIndex] = useState(0);
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('oldest');
const inputRef = useRef<HTMLTextAreaElement>(null);
const { t, language } = useCollaborationTranslation();
const { t } = useCollaborationTranslation();
// The >= 7d timestamp is a date, so it reads the DISPLAY locale, never the
// UI language: a regional locale (`de-CH` under an English UI) must reach it
// (objectui#10375). Provider-safe like `t`: with no provider mounted the
// hook answers the same UI-language tag this branch used before.
const displayLocale = useDisplayLocale();

const filteredMentions = useMemo(() => {
if (mentionQuery === null) return [];
Expand Down Expand Up @@ -545,7 +562,7 @@ export function CommentThread({
// Header
React.createElement('div', { style: styles.commentHeader },
React.createElement('span', { style: styles.authorName }, comment.author.name),
React.createElement('span', { style: styles.timestamp }, formatTimestamp(comment.createdAt, t, language)),
React.createElement('span', { style: styles.timestamp }, formatTimestamp(comment.createdAt, t, displayLocale)),
comment.updatedAt
? React.createElement('span', { style: styles.timestamp }, t('collaboration.edited'))
: null,
Expand Down
Loading
Loading