diff --git a/changelog/+ifc-2721-date-rendering.added.md b/changelog/+ifc-2721-date-rendering.added.md new file mode 100644 index 0000000000..d34e207439 --- /dev/null +++ b/changelog/+ifc-2721-date-rendering.added.md @@ -0,0 +1 @@ +The web interface now renders dates and times using your effective preferences — your personal `date_format` and `timezone`, falling back to the organisation default and then to your browser's locale and timezone when neither is set. Preferences affect display only; stored values are unchanged. diff --git a/docs/docs/deploy-manage/user-management/managing-preferences.mdx b/docs/docs/deploy-manage/user-management/managing-preferences.mdx new file mode 100644 index 0000000000..282f9e4f30 --- /dev/null +++ b/docs/docs/deploy-manage/user-management/managing-preferences.mdx @@ -0,0 +1,140 @@ +--- +title: Managing preferences +--- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Managing preferences + +Preferences control how Infrahub renders dates and times in the web interface. Each user sets their own preferences, and administrators set organisation-wide defaults that apply to everyone who has not chosen their own. + +Two preferences are available today: **date format** and **timezone**. + +## How preferences resolve + +Infrahub resolves each preference field on its own, in this order: + +1. **Your value** — the value you set for yourself. +2. **The organisation default** — the value an administrator set for everyone. +3. **The browser default** — your browser's locale for date formatting, and your browser's timezone. + +Resolution is per field, so you can take the date format from your own value and the timezone from the organisation default at the same time. A field you never set falls through to the next layer; it is not stored until you set it. + +## Available preferences + +### Date format + +The date format is a semantic key, not a rendering pattern. Each preset renders both a date and a time. The web interface renders the following presets: + +| Key | Format | Example | +|---|---|---| +| `ISO_DATETIME` (default) | `yyyy-MM-dd HH:mm` | `2026-07-01 14:30` | +| `ISO_DATETIME_SECONDS` | `yyyy-MM-dd HH:mm:ss` | `2026-07-01 14:30:00` | +| `ISO_8601` | `yyyy-MM-dd'T'HH:mm:ssXXX` | `2026-07-01T14:30:00+02:00` | +| `EU_DATETIME` | `dd/MM/yyyy HH:mm` | `01/07/2026 14:30` | +| `US_12H` | `MM/dd/yyyy hh:mm a` | `07/01/2026 02:30 PM` | + +When you select a format, the interface shows a live example next to the field. + +### Timezone + +The timezone is an IANA name, such as `Europe/Paris` or `UTC`. The picker lists the timezones your browser supports. A field left unset uses your browser's own timezone. + +## Set your preferences + + + + +1. Open **Account settings** from the user menu in the left sidebar. +2. On the **Profile** tab, find the **Preferences** card. +3. Select a **Date format** and a **Timezone**. +4. Click **Save**. + +A field you leave on **Automatic (inherited)** takes the organisation default, or your browser default when no organisation default is set. The information icon next to each field shows where the current value comes from. + +To clear an override and go back to the inherited value, re-select the value that is currently applied. + + + + +Write your own preferences with `InfrahubSetPreferences` using `scope: USER`. The mutation only ever writes your own row — there is no account argument. + +```graphql +mutation { + InfrahubSetPreferences(scope: USER, date_format: EU_DATETIME, timezone: "Europe/Paris") { + ok + date_format + timezone + } +} +``` + +An omitted argument leaves that field unchanged. An explicit `null` clears the field, so it falls back to the organisation default or your browser default. + +Read your resolved values — the ones the interface renders with — with `InfrahubEffectivePreferences`. Each field returns its `value` and the `source` it resolved from (`USER`, `GLOBAL`, or `DEFAULT`). + +```graphql +query { + InfrahubEffectivePreferences { + date_format { + value + source + } + timezone { + value + source + } + } +} +``` + + + + +## Set organisation defaults + +Organisation defaults apply to every user who has not set their own value. Setting them requires the `manage_global_preferences` permission (super administrators have it implicitly). See the [permissions reference](../../reference/permissions.mdx) for the full list of permissions. + + + + +1. Open **Global preferences** from the user menu in the left sidebar. The menu item is visible only to users with the `manage_global_preferences` permission. +2. Select a **Date format** and a **Timezone**. +3. Click **Save**. + +A field left on **Automatic (browser default)** sets no organisation default for that field, so users without their own value fall back to their browser default. + + + + +Write organisation defaults with `InfrahubSetPreferences` using `scope: GLOBAL`. The mutation enforces the `manage_global_preferences` permission before it writes. + +```graphql +mutation { + InfrahubSetPreferences(scope: GLOBAL, date_format: ISO_DATETIME, timezone: "UTC") { + ok + date_format + timezone + } +} +``` + +Read the raw organisation defaults with `InfrahubGlobalPreferences`. This query also requires the `manage_global_preferences` permission. + +```graphql +query { + InfrahubGlobalPreferences { + date_format + timezone + } +} +``` + + + + +## Best practices + +- **Set an organisation default for the timezone when your teams work in one region.** Everyone then reads timestamps in the same zone until they choose their own. +- **Leave the date format on the default (`ISO_DATETIME`) unless your organisation has a house style.** The ISO presets sort and compare the same way in every locale. +- **Preferences affect display only.** They change how the web interface renders timestamps; they never change the stored data. A timezone preference renders each timestamp in that zone without altering the underlying value. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 5bbe08c329..7b071c0494 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -482,6 +482,7 @@ const sidebars: SidebarsConfig = { }, // Managing API Tokens (PR 14) { type: 'doc', id: 'deploy-manage/user-management/managing-api-tokens', label: 'Managing API tokens' }, + { type: 'doc', id: 'deploy-manage/user-management/managing-preferences', label: 'Managing preferences' }, ], }, ], diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index a9cfd4143f..9296f8034d 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -95,6 +95,23 @@ async def login(page: Page, username: str, password: str) -> None: await expect(page.get_by_test_id(AUTHENTICATED_MENU_TRIGGER)).to_be_visible() +async def select_combobox_option(page: Page, label: str, option: str) -> None: + """Open a Combobox by its accessible name and pick an option by its exact label. + + The preference forms use a searchable Combobox — a button trigger (its + accessible name is ``label``) that opens a dialog holding a searchbox and a + listbox. Typing into the search narrows the list, which the virtualized + timezone picker needs (only rendered options are in the DOM) and the short + date-format list tolerates. The option is then clicked by its exact label, so + a value that is a prefix of another (``yyyy-MM-dd HH:mm`` vs + ``yyyy-MM-dd HH:mm:ss``) still resolves unambiguously. + """ + await page.get_by_role("button", name=label, exact=True).click() + dialog = page.get_by_role("dialog", name=label) + await dialog.get_by_role("searchbox").fill(option) + await dialog.get_by_role("option", name=option, exact=True).click() + + class BranchAPI: """Port of tests/e2e/utils/graphql.ts branch helpers. diff --git a/tests/e2e/preferences/test_global_preferences.py b/tests/e2e/preferences/test_global_preferences.py new file mode 100644 index 0000000000..9682620be2 --- /dev/null +++ b/tests/e2e/preferences/test_global_preferences.py @@ -0,0 +1,66 @@ +"""E2E coverage for organisation-wide default preferences. + +A user holding ``manage_global_preferences`` sets organisation defaults on the +Global preferences page. Every user without a personal override then inherits +those defaults — surfaced in the Preferences card as the "organisation default" +source. + +Uses the read-only account (from the RBAC slice) as the inheriting user, so the +test declares the ``data_rbac`` dependency transitively via ``read_only_page``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from helpers import select_combobox_option +from playwright.async_api import expect + +pytestmark = pytest.mark.shard_foundation + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from infrahub_sdk import InfrahubClient + from playwright.async_api import Page + +# US 12-hour preset — distinct from the ISO default so an inherited value is +# unambiguous. +GLOBAL_DATE_FORMAT = "MM/dd/yyyy hh:mm a" + + +class TestGlobalPreferences: + @pytest.fixture(autouse=True) + async def reset_global_preferences(self, infrahub_client: InfrahubClient) -> AsyncGenerator[None, None]: + """Clear the organisation-wide defaults after the test. + + The global row is shared by every account on the session instance, so it + must be reset or later tests inherit a stale default. + """ + yield + await infrahub_client.execute_graphql( + query=("mutation { InfrahubSetPreferences(scope: GLOBAL, date_format: null, timezone: null) { ok } }"), + ) + + async def test_global_default_is_inherited_by_users_without_an_override( + self, admin_page: Page, read_only_page: Page + ) -> None: + # An administrator sets the organisation default. + await admin_page.goto("/global-preferences") + await select_combobox_option(admin_page, "Date format", GLOBAL_DATE_FORMAT) + await admin_page.get_by_role("button", name="Save", exact=True).click() + await expect(admin_page.get_by_text("Global preferences updated")).to_be_visible() + + # A user with no personal override sees the field inherit, and the source + # hint attributes it to the organisation default. + await read_only_page.goto("/profile") + await expect(read_only_page.get_by_role("button", name="Date format", exact=True)).to_contain_text( + "Automatic (inherited)" + ) + + # Focus (not hover): React Aria opens the tooltip immediately on keyboard + # focus, while hover goes through the warm-up delay — the deterministic + # option for CI. + await read_only_page.get_by_role("button", name="Where this value comes from").first.focus() + await expect(read_only_page.get_by_text("From the organisation default")).to_be_visible() diff --git a/tests/e2e/preferences/test_preferences_permissions.py b/tests/e2e/preferences/test_preferences_permissions.py new file mode 100644 index 0000000000..42e89898ce --- /dev/null +++ b/tests/e2e/preferences/test_preferences_permissions.py @@ -0,0 +1,50 @@ +"""E2E coverage for the manage_global_preferences permission gate. + +The Global preferences page is gated by the ``manage_global_preferences`` global +permission. An administrator (super admin, who holds it implicitly) sees the +account-menu entry and the editor; a user without the permission never sees the +menu entry, and a direct visit to the page is refused. + +Uses the read-only account (from the RBAC slice) as the unprivileged user, so the +test declares the ``data_rbac`` dependency transitively via ``read_only_page``. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +import pytest +from playwright.async_api import expect + +pytestmark = pytest.mark.shard_foundation + +if TYPE_CHECKING: + from playwright.async_api import Page + + +class TestPreferencesPermissions: + async def test_admin_sees_the_global_preferences_menu_entry_and_editor(self, admin_page: Page) -> None: + await admin_page.goto("/") + await admin_page.get_by_test_id("authenticated-menu-trigger").click() + await expect(admin_page.get_by_role("menuitem", name="Global preferences")).to_be_visible() + + await admin_page.get_by_role("menuitem", name="Global preferences").click() + await expect(admin_page).to_have_url(re.compile(r"/global-preferences$")) + await expect(admin_page.get_by_role("button", name="Save", exact=True)).to_be_visible() + + async def test_non_admin_cannot_see_or_open_global_preferences(self, read_only_page: Page) -> None: + await read_only_page.goto("/") + # The account menu opens, but the gated entry is absent. + await read_only_page.get_by_test_id("authenticated-menu-trigger").click() + await expect(read_only_page.get_by_role("menuitem", name="Account settings")).to_be_visible() + await expect(read_only_page.get_by_role("menuitem", name="Global preferences")).to_have_count(0) + await read_only_page.keyboard.press("Escape") + + # A direct visit to the gated page is refused. The detailed message sits + # inside a collapsed accordion, so expand it before asserting on it. + await read_only_page.goto("/global-preferences") + await expect(read_only_page.get_by_text("You can't access this view")).to_be_visible() + await read_only_page.get_by_text("You can't access this view").click() + await expect(read_only_page.get_by_text("You don't have permission to edit global preferences")).to_be_visible() + await expect(read_only_page.get_by_role("button", name="Save", exact=True)).to_have_count(0) diff --git a/tests/e2e/preferences/test_user_preferences.py b/tests/e2e/preferences/test_user_preferences.py new file mode 100644 index 0000000000..e06b3011f0 --- /dev/null +++ b/tests/e2e/preferences/test_user_preferences.py @@ -0,0 +1,85 @@ +"""E2E coverage for personal user preferences. + +Each user sets their own date format and timezone from the Preferences card on +the account Profile tab. A saved value persists across a reload; re-selecting the +currently-applied value clears the override so the field inherits again. + +Runs as admin only (the bootstrap account), so no demo data is required. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from helpers import select_combobox_option +from playwright.async_api import expect + +pytestmark = pytest.mark.shard_foundation + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from infrahub_sdk import InfrahubClient + from playwright.async_api import Page + +# A preset that differs from every layer's default, so a persisted value is +# unambiguous. The option's accessible name is its date-fns pattern (the label +# the Combobox shows). +EU_DATE_FORMAT = "dd/MM/yyyy HH:mm" +TIMEZONE = "Asia/Tokyo" + + +class TestUserPreferences: + @pytest.fixture(autouse=True) + async def reset_admin_preferences(self, infrahub_client: InfrahubClient) -> AsyncGenerator[None, None]: + """Clear the admin account's own preference row after each test. + + Preferences persist per account on the shared session instance, so an + override left behind would leak into later tests. An explicit ``null`` + resets each field (an omitted field would be left unchanged). + """ + yield + await infrahub_client.execute_graphql( + query=("mutation { InfrahubSetPreferences(scope: USER, date_format: null, timezone: null) { ok } }"), + ) + + async def test_set_personal_preferences_persist_across_reload(self, admin_page: Page) -> None: + await admin_page.goto("/profile") + await expect(admin_page.get_by_text("Preferences", exact=True)).to_be_visible() + + await select_combobox_option(admin_page, "Date format", EU_DATE_FORMAT) + await select_combobox_option(admin_page, "Timezone", TIMEZONE) + await admin_page.get_by_role("button", name="Save", exact=True).click() + await expect(admin_page.get_by_text("Preferences updated")).to_be_visible() + + # The saved override survives a full reload (it is read back from the server). + await admin_page.reload() + await expect(admin_page.get_by_role("button", name="Date format", exact=True)).to_contain_text(EU_DATE_FORMAT) + await expect(admin_page.get_by_role("button", name="Timezone", exact=True)).to_contain_text(TIMEZONE) + + async def test_reselecting_the_current_value_clears_the_override(self, admin_page: Page) -> None: + await admin_page.goto("/profile") + + # Set an override first. + await select_combobox_option(admin_page, "Date format", EU_DATE_FORMAT) + await admin_page.get_by_role("button", name="Save", exact=True).click() + await expect(admin_page.get_by_text("Preferences updated")).to_be_visible() + # Let the toast auto-dismiss so the second save's toast is unambiguous; + # otherwise the next visibility check can pass against this stale toast + # and the reload below races the in-flight mutation. + await expect(admin_page.get_by_text("Preferences updated")).not_to_be_visible() + + # Re-selecting the currently-applied value clears the override; the field + # falls back to the inherited value and shows its placeholder again. + await select_combobox_option(admin_page, "Date format", EU_DATE_FORMAT) + await expect(admin_page.get_by_role("button", name="Date format", exact=True)).to_contain_text( + "Automatic (inherited)" + ) + await admin_page.get_by_role("button", name="Save", exact=True).click() + await expect(admin_page.get_by_text("Preferences updated")).to_be_visible() + + await admin_page.reload() + await expect(admin_page.get_by_role("button", name="Date format", exact=True)).to_contain_text( + "Automatic (inherited)" + )