From 82600b321f2721042c4aa23cbbb3a0859092a6b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Lem=C3=A9nager?= Date: Fri, 17 Jul 2026 17:03:09 +0200 Subject: [PATCH 1/5] docs(preferences): user-facing docs, changelog, and e2e tests (IFC-2737) Add the user-facing "Managing preferences" page under Deployment & Management > User Management & Security (single page, Web + GraphQL tabs): precedence (user > organisation default > browser), the date-format presets, setting personal preferences and organisation defaults, and the manage_global_preferences gate. Register it in the sidebar after "Managing API tokens". Add the missing changelog fragment for preference-driven date/time rendering (IFC-2721); the existing fragments only covered setting preferences. Add Python e2e coverage: personal round-trip + clear-override, global-default inheritance, and the permission gate. Introduce a shared select_combobox_option helper for the preference forms. Facts grounded in the code on this branch (literal UI labels, the "re-select to clear" reset, the InfrahubSetPreferences/InfrahubEffectivePreferences surface). Co-Authored-By: Claude Opus 4.8 --- changelog/+ifc-2721-date-rendering.added.md | 1 + .../user-management/managing-preferences.mdx | 141 ++++++++++++++++++ docs/sidebars.ts | 1 + tests/e2e/helpers.py | 17 +++ .../preferences/test_global_preferences.py | 63 ++++++++ .../test_preferences_permissions.py | 42 ++++++ .../e2e/preferences/test_user_preferences.py | 78 ++++++++++ 7 files changed, 343 insertions(+) create mode 100644 changelog/+ifc-2721-date-rendering.added.md create mode 100644 docs/docs/deploy-manage/user-management/managing-preferences.mdx create mode 100644 tests/e2e/preferences/test_global_preferences.py create mode 100644 tests/e2e/preferences/test_preferences_permissions.py create mode 100644 tests/e2e/preferences/test_user_preferences.py diff --git a/changelog/+ifc-2721-date-rendering.added.md b/changelog/+ifc-2721-date-rendering.added.md new file mode 100644 index 00000000000..d34e207439a --- /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 00000000000..ac663e3772d --- /dev/null +++ b/docs/docs/deploy-manage/user-management/managing-preferences.mdx @@ -0,0 +1,141 @@ +--- +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 **Account settings** from the user menu in the left sidebar. +2. Open the **Global preferences** tab. The tab is visible only to users with the `manage_global_preferences` permission. +3. Select a **Date format** and a **Timezone**. +4. 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 5bbe08c3290..7b071c04948 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 a9cfd4143f3..9296f8034dc 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 00000000000..6f01212fcba --- /dev/null +++ b/tests/e2e/preferences/test_global_preferences.py @@ -0,0 +1,63 @@ +"""E2E coverage for organisation-wide default preferences (IFC-2720 / IFC-2722). + +A user holding ``manage_global_preferences`` sets organisation defaults on the +Global preferences tab. 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("/profile/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)" + ) + + await read_only_page.get_by_role("button", name="Where this value comes from").first.hover() + 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 00000000000..b49841efee2 --- /dev/null +++ b/tests/e2e/preferences/test_preferences_permissions.py @@ -0,0 +1,42 @@ +"""E2E coverage for the manage_global_preferences permission gate (IFC-2722). + +The Global preferences tab and page are gated by the ``manage_global_preferences`` +global permission. An administrator (super admin, who holds it implicitly) sees +the tab and the editor; a user without the permission never sees the tab, 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 + +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_tab_and_editor(self, admin_page: Page) -> None: + await admin_page.goto("/profile") + await expect(admin_page.get_by_role("link", name="Global preferences")).to_be_visible() + + await admin_page.get_by_role("link", name="Global preferences").click() + await expect(admin_page).to_have_url("**/profile/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("/profile") + # The Profile/Tokens/Password tabs render; the gated tab does not. + await expect(read_only_page.get_by_role("link", name="Profile", exact=True)).to_be_visible() + await expect(read_only_page.get_by_role("link", name="Global preferences")).to_have_count(0) + + # A direct visit to the gated page is refused. + await read_only_page.goto("/profile/global-preferences") + await expect(read_only_page.get_by_text("You don't have permission to edit global preferences")).to_be_visible() diff --git a/tests/e2e/preferences/test_user_preferences.py b/tests/e2e/preferences/test_user_preferences.py new file mode 100644 index 00000000000..d049e6ac5d1 --- /dev/null +++ b/tests/e2e/preferences/test_user_preferences.py @@ -0,0 +1,78 @@ +"""E2E coverage for personal user preferences (IFC-2720 / IFC-2721). + +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() + + # 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 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)" + ) From ba570fa8dcf88de7142a51a290ccc8148e12a6ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Lem=C3=A9nager?= Date: Thu, 23 Jul 2026 11:52:23 +0200 Subject: [PATCH 2/5] docs(preferences): follow the global-preferences move to the account menu The global preferences page moved from a profile tab to a standalone /global-preferences route behind a permission-gated account-menu item; the docs step and both e2e tests now walk that flow. Also de-flake the tests that never went green: assert the URL with a regex (a string pattern resolves against the base URL and the glob never matched) and open the source tooltip via focus, which React Aria shows immediately, instead of the delay-prone hover. Co-Authored-By: Claude Fable 5 --- .../user-management/managing-preferences.mdx | 7 ++-- .../preferences/test_global_preferences.py | 9 ++++-- .../test_preferences_permissions.py | 32 +++++++++++-------- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/docs/docs/deploy-manage/user-management/managing-preferences.mdx b/docs/docs/deploy-manage/user-management/managing-preferences.mdx index ac663e3772d..282f9e4f303 100644 --- a/docs/docs/deploy-manage/user-management/managing-preferences.mdx +++ b/docs/docs/deploy-manage/user-management/managing-preferences.mdx @@ -98,10 +98,9 @@ Organisation defaults apply to every user who has not set their own value. Setti -1. Open **Account settings** from the user menu in the left sidebar. -2. Open the **Global preferences** tab. The tab is visible only to users with the `manage_global_preferences` permission. -3. Select a **Date format** and a **Timezone**. -4. Click **Save**. +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. diff --git a/tests/e2e/preferences/test_global_preferences.py b/tests/e2e/preferences/test_global_preferences.py index 6f01212fcba..d292d8c463c 100644 --- a/tests/e2e/preferences/test_global_preferences.py +++ b/tests/e2e/preferences/test_global_preferences.py @@ -1,7 +1,7 @@ """E2E coverage for organisation-wide default preferences (IFC-2720 / IFC-2722). A user holding ``manage_global_preferences`` sets organisation defaults on the -Global preferences tab. Every user without a personal override then inherits +Global preferences page. Every user without a personal override then inherits those defaults — surfaced in the Preferences card as the "organisation default" source. @@ -47,7 +47,7 @@ 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("/profile/global-preferences") + 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() @@ -59,5 +59,8 @@ async def test_global_default_is_inherited_by_users_without_an_override( "Automatic (inherited)" ) - await read_only_page.get_by_role("button", name="Where this value comes from").first.hover() + # 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 index b49841efee2..782c3ec5a50 100644 --- a/tests/e2e/preferences/test_preferences_permissions.py +++ b/tests/e2e/preferences/test_preferences_permissions.py @@ -1,9 +1,9 @@ """E2E coverage for the manage_global_preferences permission gate (IFC-2722). -The Global preferences tab and page are gated by the ``manage_global_preferences`` -global permission. An administrator (super admin, who holds it implicitly) sees -the tab and the editor; a user without the permission never sees the tab, and a -direct visit to the page is refused. +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``. @@ -11,6 +11,7 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING import pytest @@ -23,20 +24,23 @@ class TestPreferencesPermissions: - async def test_admin_sees_the_global_preferences_tab_and_editor(self, admin_page: Page) -> None: - await admin_page.goto("/profile") - await expect(admin_page.get_by_role("link", name="Global preferences")).to_be_visible() + 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("link", name="Global preferences").click() - await expect(admin_page).to_have_url("**/profile/global-preferences") + 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("/profile") - # The Profile/Tokens/Password tabs render; the gated tab does not. - await expect(read_only_page.get_by_role("link", name="Profile", exact=True)).to_be_visible() - await expect(read_only_page.get_by_role("link", name="Global preferences")).to_have_count(0) + 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. - await read_only_page.goto("/profile/global-preferences") + await read_only_page.goto("/global-preferences") await expect(read_only_page.get_by_text("You don't have permission to edit global preferences")).to_be_visible() From 716e6899e480d59e8cd6caadda76a6f1a3ddc284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Lem=C3=A9nager?= Date: Thu, 23 Jul 2026 11:53:38 +0200 Subject: [PATCH 3/5] chore(e2e): drop work-item IDs from the preference test docstrings Co-Authored-By: Claude Fable 5 --- tests/e2e/preferences/test_global_preferences.py | 2 +- tests/e2e/preferences/test_preferences_permissions.py | 2 +- tests/e2e/preferences/test_user_preferences.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/preferences/test_global_preferences.py b/tests/e2e/preferences/test_global_preferences.py index d292d8c463c..9682620be27 100644 --- a/tests/e2e/preferences/test_global_preferences.py +++ b/tests/e2e/preferences/test_global_preferences.py @@ -1,4 +1,4 @@ -"""E2E coverage for organisation-wide default preferences (IFC-2720 / IFC-2722). +"""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 diff --git a/tests/e2e/preferences/test_preferences_permissions.py b/tests/e2e/preferences/test_preferences_permissions.py index 782c3ec5a50..2c1d4b492c0 100644 --- a/tests/e2e/preferences/test_preferences_permissions.py +++ b/tests/e2e/preferences/test_preferences_permissions.py @@ -1,4 +1,4 @@ -"""E2E coverage for the manage_global_preferences permission gate (IFC-2722). +"""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 diff --git a/tests/e2e/preferences/test_user_preferences.py b/tests/e2e/preferences/test_user_preferences.py index d049e6ac5d1..a6c498ff31c 100644 --- a/tests/e2e/preferences/test_user_preferences.py +++ b/tests/e2e/preferences/test_user_preferences.py @@ -1,4 +1,4 @@ -"""E2E coverage for personal user preferences (IFC-2720 / IFC-2721). +"""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 From f155760c38a6ae96b0505fc4890f87e81820cd48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Lem=C3=A9nager?= Date: Thu, 23 Jul 2026 15:15:39 +0200 Subject: [PATCH 4/5] fix(e2e): expand the unauthorized accordion before asserting the permission message The UnauthorizedScreen renders the detailed message inside a collapsed Accordion, so the text is not in the DOM until the title is clicked. Assert the visible title first, expand it, then check the message. Co-Authored-By: Claude Fable 5 --- tests/e2e/preferences/test_preferences_permissions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/e2e/preferences/test_preferences_permissions.py b/tests/e2e/preferences/test_preferences_permissions.py index 2c1d4b492c0..42e89898ce4 100644 --- a/tests/e2e/preferences/test_preferences_permissions.py +++ b/tests/e2e/preferences/test_preferences_permissions.py @@ -41,6 +41,10 @@ async def test_non_admin_cannot_see_or_open_global_preferences(self, read_only_p 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. + # 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) From cbf87a2defb91fa66a0b70258ff9b04992c387dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Lem=C3=A9nager?= Date: Thu, 23 Jul 2026 15:41:40 +0200 Subject: [PATCH 5/5] fix(e2e): deduplicate the success toast between the two preference saves The second 'Preferences updated' check could match the first save's still-visible toast, letting the reload race (and cancel) the in-flight clear mutation. Wait for the first toast to dismiss, and assert the trigger shows the inherited placeholder before saving the clear. Co-Authored-By: Claude Fable 5 --- tests/e2e/preferences/test_user_preferences.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/e2e/preferences/test_user_preferences.py b/tests/e2e/preferences/test_user_preferences.py index a6c498ff31c..e06b3011f0c 100644 --- a/tests/e2e/preferences/test_user_preferences.py +++ b/tests/e2e/preferences/test_user_preferences.py @@ -65,10 +65,17 @@ async def test_reselecting_the_current_value_clears_the_override(self, admin_pag 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()