From e275e15ad674ef9948c938b0174424f46b522c6b Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 7 Oct 2026 18:43:56 -0400 Subject: [PATCH] Open groups and public workspaces from V2 User Settings Each row on the Groups and Public tabs now has Open next to Set active, so a workspace can be visited from settings instead of only made active. Open navigates by immutable id through WorkspaceKind.openPath, built with the reviewed groupWorkspacePath/publicWorkspacePath builders, and never activates from the tab: the group page activates the group it shows, and a public page leaves the active public workspace alone, as with the directories' Open. It shares Set active's busy gating, and an id the path builder refuses is reported in the tab's error line. Rows now wrap like the directory rows, so on a phone the actions move below the name instead of squeezing the name and role badge. Bump to 0.261.294 with tests, guide, feature doc and release notes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../components/settings/WorkspaceListTab.tsx | 68 +++++-- application/v2_ui/src/lib/workspaces.ts | 10 + .../features/V2_USER_SETTINGS_REDESIGN.md | 36 +++- docs/explanation/release_notes.md | 10 + docs/guides/update-profile-preferences.md | 11 +- .../test_v2_group_workspace_context_logic.mjs | 13 +- ...test_v2_public_workspace_context_logic.mjs | 27 ++- functional_tests/test_v2_settings_tabs.py | 68 ++++++- ui_tests/test_v2_settings_workspace_open.py | 172 ++++++++++++++++++ 10 files changed, 392 insertions(+), 25 deletions(-) create mode 100644 ui_tests/test_v2_settings_workspace_open.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 4206520c9..05464869a 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.293" +VERSION = "0.261.294" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/v2_ui/src/components/settings/WorkspaceListTab.tsx b/application/v2_ui/src/components/settings/WorkspaceListTab.tsx index 80443cae0..974959730 100644 --- a/application/v2_ui/src/components/settings/WorkspaceListTab.tsx +++ b/application/v2_ui/src/components/settings/WorkspaceListTab.tsx @@ -6,8 +6,9 @@ // been fetched would quietly hide matches. import { useCallback, useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { clsx } from 'clsx'; -import { Check, Globe2, Loader2, Search, Users } from 'lucide-react'; +import { ArrowUpRight, Check, Globe2, Loader2, Search, Users } from 'lucide-react'; import { ApiError } from '../../lib/apiClient'; import type { WorkspaceKind, WorkspaceSummary } from '../../lib/workspaces'; import { GlassPanel, Skeleton } from '../ui/primitives'; @@ -23,13 +24,14 @@ function RoleBadge({ role }: { role?: string }) { return null; } return ( - + {role} ); } export function WorkspaceListTab({ kind }: { kind: WorkspaceKind }) { + const navigate = useNavigate(); const needsReconciliation = useGroupWorkspaceStore((state) => kind.scope === 'group' && state.needsReconciliation); const [items, setItems] = useState([]); const [page, setPage] = useState(1); @@ -128,6 +130,18 @@ export function WorkspaceListTab({ kind }: { kind: WorkspaceKind }) { } }; + // Open never activates from here; the page it lands on decides, as with the directories. + const openWorkspace = (workspace: WorkspaceSummary) => { + let path: string; + try { + path = kind.openPath(workspace.id); + } catch { + setActionError(`Could not open that ${kind.noun}.`); + return; + } + navigate(path); + }; + const lastPage = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); const title = `Your ${kind.pluralNoun}`; @@ -186,15 +200,17 @@ export function WorkspaceListTab({ kind }: { kind: WorkspaceKind }) {
    {items.map((workspace) => (
  • - -
    -
    + {/* Wraps like the directory rows: on a narrow screen the actions drop + below the name instead of squeezing it to a few letters. */} + +
    +
    {workspace.name || 'Untitled'} {workspace.status && workspace.status !== 'active' && ( - + {workspace.status} )} @@ -206,27 +222,41 @@ export function WorkspaceListTab({ kind }: { kind: WorkspaceKind }) { )}
    - {workspace.isActive ? ( - - Active - - ) : ( +
    + {workspace.isActive ? ( + + Active + + ) : ( + + )} - )} +
  • ))} diff --git a/application/v2_ui/src/lib/workspaces.ts b/application/v2_ui/src/lib/workspaces.ts index 042311af7..85f1cabce 100644 --- a/application/v2_ui/src/lib/workspaces.ts +++ b/application/v2_ui/src/lib/workspaces.ts @@ -11,6 +11,8 @@ // GET; the dedicated setActive routes say plainly what they do and report why they refused. import { api } from './apiClient'; +import { groupWorkspacePath } from './groupWorkspaceNavigation'; +import { publicWorkspacePath } from './publicWorkspaceNavigation'; import { isRecord } from './workspaceAuthoring'; import { useBootstrapStore } from '../stores/bootstrapStore'; @@ -54,6 +56,12 @@ export interface WorkspaceKind { list: (page: number, pageSize: number, search: string, signal?: AbortSignal) => Promise; /** Make one active. Rejects with a message the server supplied. */ setActive: (id: string) => Promise; + /** + * Where Open goes for a row: the V2 workspace page, addressed by its immutable id. Opening + * never activates anything here; as with the directories' Open, the page decides. A group + * page activates the group it shows, and a public page leaves the active one alone. + */ + openPath: (id: string) => string; /** What one of these is called, for empty states and labels. */ noun: string; pluralNoun: string; @@ -107,6 +115,7 @@ export const GROUP_WORKSPACES: WorkspaceKind = { // 400 missing id, 404 unknown group, 403 not a member — all surfaced as written. await api.patch('/api/groups/setActive', { groupId: id }); }, + openPath: (id) => groupWorkspacePath(id), noun: 'group', pluralNoun: 'groups', classicHref: '/profile?tab=groups', @@ -137,6 +146,7 @@ export const PUBLIC_WORKSPACES: WorkspaceKind = { setActive: async (id) => { await api.patch('/api/public_workspaces/setActive', { workspaceId: id }); }, + openPath: (id) => publicWorkspacePath(id), noun: 'public workspace', pluralNoun: 'public workspaces', classicHref: '/profile?tab=public-workspaces', diff --git a/docs/explanation/features/V2_USER_SETTINGS_REDESIGN.md b/docs/explanation/features/V2_USER_SETTINGS_REDESIGN.md index 764be9efc..352e99637 100644 --- a/docs/explanation/features/V2_USER_SETTINGS_REDESIGN.md +++ b/docs/explanation/features/V2_USER_SETTINGS_REDESIGN.md @@ -1,7 +1,8 @@ # V2 User Settings Redesign **Implemented in version: 0.261.277** (layout); **0.261.278** (voice, audio and retention); -**0.261.279** (fact memory and Microsoft 365); **0.261.280** (guided tours and Latest Features) +**0.261.279** (fact memory and Microsoft 365); **0.261.280** (guided tours and Latest Features); +**0.261.294** (Open on Groups and Public rows) ## Overview @@ -15,6 +16,8 @@ Profile page in stages: and personal retention (0.261.278). 3. Fact memory workbench and Microsoft 365 sharing and workflows (0.261.279). 4. Guided tours and the Latest Features shortcut (0.261.280). +5. Opening a group or public workspace from its row, as the classic page's **Manage** link + does (0.261.294). Dependencies: the V2 React interface (`application/v2_ui`) and the user settings API (`/api/user/settings`). @@ -154,6 +157,28 @@ Both surfaces write `latestFeaturesHiddenVersion`, shared with the classic page. applies only to the version it was saved for, so the shortcut comes back after an upgrade. `lib/latestFeaturesNav.ts` holds the logic both surfaces use. +### Groups and Public workspaces + +`components/settings/WorkspaceListTab.tsx` renders both tabs from a `WorkspaceKind` in +`lib/workspaces.ts`, which holds what differs between the two: the list route, the setActive +route and its body key, and where a row opens. + +- Each row shows the workspace name, the caller's role, any status other than active, an + **Active** badge or a **Set active** button, and **Open**. +- **Set active** goes through the dedicated setActive route and then re-reads the list, because + the server resolves which workspace is active. +- **Open** (0.261.294) navigates to `WorkspaceKind.openPath(id)`, which is + `groupWorkspacePath(id)` (`/groups/`) or `publicWorkspacePath(id)` (`/public/`). Both + are reviewed same-origin builders in `scripts/check_xss_sinks.py`. Like the directories' + **Open**, the tab never activates anything itself; the page it lands on decides. A group page + activates the group it shows if it isn't active already, because a group's routes work in the + active group. A public workspace page loads by id and leaves the active public workspace alone. +- **Open** is disabled while a switch or a workspace-selection refresh is in progress, the same + as **Set active**, so two activations never race. An id the path builder refuses is reported + in the tab's error line. +- Rows wrap the way the directory rows do. On a narrow screen the buttons move below the name, + and the role and status badges keep their width. + ### Violations visibility The Violations tab is always listed, as on the classic Profile page. When neither @@ -191,6 +216,15 @@ collapse or expand it, and the **On this page** index to move between cards. accepted by the route, every tour step's anchor present, launchers and the Help and guidance group, the bootstrap navigation entry, and a Node run of the shared tour and Latest Features logic. +- `functional_tests/test_v2_settings_tabs.py`: each kind's Open path, the reviewed builders + and routes behind it, and an Open handler that never activates. +- `functional_tests/test_v2_group_workspace_context_logic.mjs` and + `functional_tests/test_v2_public_workspace_context_logic.mjs`: the real `openPath` output, + identifier encoding, and refused identifiers. +- `ui_tests/test_v2_settings_workspace_open.py`: against the built SPA, Open on a group that + isn't active (the group page activates it once), on the active group (no activation), and on + a public workspace (no activation), plus the row layout in both themes at desktop and phone + widths. - Related fixes: [Feedback My Routes Filter Unpack Fix](../fixes/FEEDBACK_MY_FILTER_UNPACK_FIX.md) and [Personal Retention Default Value Fix](../fixes/PERSONAL_RETENTION_DEFAULT_VALUE_FIX.md). diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 04d542fbe..0e1d0c7c3 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,16 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.294)** + +#### User Interface Enhancements + +* **Open Groups and Public Workspaces From User Settings** + * In V2 **User Settings**, every row on the **Groups** and **Public** tabs now has **Open** next to **Set active**, so you can go straight to a workspace you own or use instead of only making it active. + * **Open** goes to the workspace by its ID, like **Open** in the group and public directories. Opening a group also makes it your active group, because a group's pages work in the active group. Opening a public workspace leaves your active one unchanged. + * On narrow screens the row's buttons wrap below the workspace name, so the name and role badge are no longer squeezed. + * (Ref: `WorkspaceListTab.tsx`, `lib/workspaces.ts` `WorkspaceKind.openPath`, [V2 User Settings Redesign](features/V2_USER_SETTINGS_REDESIGN.md), `ui_tests/test_v2_settings_workspace_open.py`) + ### **(v0.261.293)** #### Bug Fixes diff --git a/docs/guides/update-profile-preferences.md b/docs/guides/update-profile-preferences.md index dbe78922b..64dcc74f1 100644 --- a/docs/guides/update-profile-preferences.md +++ b/docs/guides/update-profile-preferences.md @@ -50,7 +50,7 @@ Profile preferences are for personal comfort and control: font size, navigation ## In the new interface In the new interface, open **User Settings** from the account menu. The left rail lists -**Preferences**, **Stats**, **Groups**, **Public workspaces**, **Feedback**, and +**Preferences**, **Stats**, **Groups**, **Public**, **Feedback**, and **Violations**. To give the settings more room, collapse the rail to icons with the button at its top. The rail stays collapsed until you expand it again, on any device. @@ -103,6 +103,13 @@ page, described in the next section: **Microsoft 365 sharing**, **Chat connectio you to confirm first. Chat reconnect opens Microsoft sign-in in a pop-up and keeps you on the page; connecting for workflows signs in through the classic Profile page and returns there. +The **Groups** and **Public** tabs list the workspaces you can use, with your role in each. +From version **0.261.294**, every row has **Open**, which takes you straight to that +workspace, next to **Set active**, which only changes where new work in this interface is +scoped and keeps you in settings. Opening a group also makes it your active group, because a +group's pages work in the active group. Opening a public workspace leaves your active public +workspace as it was. + **Violations** is always listed. If content safety is off for your application, the tab says so instead of showing an empty list. @@ -146,5 +153,7 @@ Each card shows a status message after saving. Reload the app and confirm the pr ## Related - [Manage notifications]({{ '/guides/manage-notifications/' | relative_url }}) +- [Manage group workspaces]({{ '/guides/manage-group-workspaces/' | relative_url }}) +- [Use public workspaces]({{ '/guides/use-public-workspaces/' | relative_url }}) - [Send feedback]({{ '/guides/send-feedback/' | relative_url }}) - [Safety settings]({{ '/admin/security/' | relative_url }}) diff --git a/functional_tests/test_v2_group_workspace_context_logic.mjs b/functional_tests/test_v2_group_workspace_context_logic.mjs index a5113ef68..f5e6da572 100644 --- a/functional_tests/test_v2_group_workspace_context_logic.mjs +++ b/functional_tests/test_v2_group_workspace_context_logic.mjs @@ -1,9 +1,10 @@ // test_v2_group_workspace_context_logic.mjs -// Version: 0.261.281 +// Version: 0.261.294 // Implemented in: 0.261.126 // Shared shell navigation and revalidation: 0.261.127 // Members section validation (M7B): 0.261.155 // Workspace navigation URL validation: 0.261.281 +// User Settings Open path for a group row: 0.261.294 // Executes the real context API and stores with controlled HTTP ordering. import assert from 'node:assert/strict'; @@ -439,6 +440,16 @@ try { const invalid = GROUP_WORKSPACES.list(1, 25, ''); await assert.rejects(invalid, /invalid data/); }); + await run('a settings row opens its group page by encoded id without calling the server', async () => { + assert.equal(GROUP_WORKSPACES.openPath('group-a'), '/groups/group-a'); + assert.equal(GROUP_WORKSPACES.openPath('group-a'), groupWorkspacePath('group-a')); + assert.equal(GROUP_WORKSPACES.openPath('group a&b'), '/groups/group%20a%26b'); + for (const invalid of ['', '.', '..', 'group/a', 'group?a', 'group#a', ' group-a']) { + assert.throws(() => GROUP_WORKSPACES.openPath(invalid), /Invalid workspace identifier/); + } + // Open only navigates; the group page it lands on owns any activation. + assert.deepEqual(calls, []); + }); await run('revalidation keeps context while loading and retains drafts on transient failure', async () => { const previous = await useGroupWorkspaceStore.getState().load('group-a'); const pending = deferred(); diff --git a/functional_tests/test_v2_public_workspace_context_logic.mjs b/functional_tests/test_v2_public_workspace_context_logic.mjs index f5d8c4727..5a2b3d90c 100644 --- a/functional_tests/test_v2_public_workspace_context_logic.mjs +++ b/functional_tests/test_v2_public_workspace_context_logic.mjs @@ -1,12 +1,14 @@ // test_v2_public_workspace_context_logic.mjs -// Version: 0.261.185 +// Version: 0.261.294 // Implemented in: 0.261.179 // The settings, activity and statistics manage sections and the settings_management hint (M10C): 0.261.185 +// User Settings Open path for a public workspace row: 0.261.294 // Executes the real public workspace context validator (isPublicWorkspaceContext in // lib/workspaceContext.ts) for the M10A additions: the optional top-level membership_management // hint, and the `members` manage section validated in the `manage` group exactly as the group // validator validates its own manage sections. M10C adds the `settings`, `activity` and // `statistics` manage sections, validated the same way, and the optional settings_management hint. +// Also executes the User Settings tab's Open path for a public workspace row (lib/workspaces.ts). // Sits beside test_v2_group_workspace_context_logic.mjs. import assert from 'node:assert/strict'; @@ -15,6 +17,8 @@ import './test_support/tsResolve.mjs'; const { PUBLIC_WORKSPACE_SECTION_IDS, PUBLIC_MANAGE_SECTION_IDS, isPublicWorkspaceContext, } = await import('../application/v2_ui/src/lib/workspaceContext.ts'); +const { publicWorkspacePath } = await import('../application/v2_ui/src/lib/publicWorkspaceNavigation.ts'); +const { PUBLIC_WORKSPACES } = await import('../application/v2_ui/src/lib/workspaces.ts'); let checks = 0; @@ -123,6 +127,27 @@ try { 'The settings hint is optional at the validator, like the other management hints.'); }); + run('a settings row opens its public workspace page by encoded id without calling the server', () => { + const originalFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (path) => { + calls.push(String(path)); + throw new Error('Building the Open path must not call the server.'); + }; + try { + assert.equal(PUBLIC_WORKSPACES.openPath('ws-a'), '/public/ws-a'); + assert.equal(PUBLIC_WORKSPACES.openPath('ws-a'), publicWorkspacePath('ws-a')); + assert.equal(PUBLIC_WORKSPACES.openPath('ws a&b'), '/public/ws%20a%26b'); + for (const invalid of ['', '.', '..', 'ws/a', 'ws?a', 'ws#a', ' ws-a']) { + assert.throws(() => PUBLIC_WORKSPACES.openPath(invalid), /Invalid workspace identifier/); + } + // Open only navigates: a public workspace is never activated by opening it from settings. + assert.deepEqual(calls, []); + } finally { + globalThis.fetch = originalFetch; + } + }); + console.log(`${checks} public workspace context validator checks passed.`); } catch (error) { console.error(error); diff --git a/functional_tests/test_v2_settings_tabs.py b/functional_tests/test_v2_settings_tabs.py index bd12a895f..a299c7deb 100644 --- a/functional_tests/test_v2_settings_tabs.py +++ b/functional_tests/test_v2_settings_tabs.py @@ -2,8 +2,9 @@ """ Functional test for the V2 settings tabs and the routes behind them. -Version: 0.261.161 +Version: 0.261.294 Implemented in: 0.261.022 +Groups and Public workspaces rows open their workspace page: 0.261.294 Each tab reads a different set of endpoints, and every field name and query parameter here was taken from the route rather than inferred. The point of pinning them is that a rename on @@ -15,6 +16,9 @@ separately, and never returns it from a later GET, so a client treating it as a setting sees what looks like a lost save. The dedicated setActive routes report *why* they refused, which is the difference between "you are not a member of that group" and silence. + +Each Groups and Public workspaces row also offers Open, which goes to that workspace's V2 page +by id. Like the directories' Open it only navigates: activation is left to the page it lands on. """ import re @@ -115,6 +119,67 @@ def test_set_active_uses_the_dedicated_routes(): return True +def test_rows_open_their_workspace_without_activating(): + """Open goes to the workspace's V2 page by id; the page it lands on owns any activation.""" + print("Testing the Open action on settings rows...") + + client = _read(V2_SRC / "lib" / "workspaces.ts") + tab = _read(V2_SRC / "components" / "settings" / "WorkspaceListTab.tsx") + app = _read(V2_SRC / "App.tsx") + checker = _read(REPO_ROOT / "scripts" / "check_xss_sinks.py") + + # Each kind owns its path, built with the same builders the workspace pages navigate with. + assert "openPath: (id: string) => string;" in client, "WorkspaceKind must declare openPath" + group_kind = client[client.index("export const GROUP_WORKSPACES"):client.index("export const PUBLIC_WORKSPACES")] + public_kind = client[client.index("export const PUBLIC_WORKSPACES"):] + assert "openPath: (id) => groupWorkspacePath(id)," in group_kind, ( + "A group row must open its group page" + ) + assert "openPath: (id) => publicWorkspacePath(id)," in public_kind, ( + "A public workspace row must open its public workspace page" + ) + + # Navigation is an XSS sink, so the builders must stay on the checker's reviewed list. + builders = re.search(r"TS_SAME_ORIGIN_URL_BUILDERS = frozenset\(\{(.*?)\}\)", checker, re.S) + assert builders, "Could not find the reviewed same-origin URL builders" + for builder in ("groupWorkspacePath", "publicWorkspacePath"): + assert f"'{builder}'" in builders.group(1), ( + f"{builder} must stay a reviewed same-origin URL builder; Open navigates with it" + ) + + # The paths are real routes rather than the catch-all, which would send the user home. + assert '} />' in app + assert '} />' in app + + # Open only navigates. A group page activates the group it shows and a public page does not, + # exactly as the directories' Open leaves it, so the tab must not activate on its own. + handler = re.search( + r"const openWorkspace = \(workspace: WorkspaceSummary\) => \{(.*?)\n \};", tab, re.S + ) + assert handler, "Could not find the Open handler" + body = handler.group(1) + assert "kind.openPath(workspace.id)" in body and "navigate(path)" in body, ( + "Open must navigate to the kind's path for the row" + ) + for activation in ("setActive", "activate(", "reconcile(", "refreshRequired"): + assert activation not in body, f"Open must not activate from the settings tab ({activation})" + assert "setActionError(" in body, "An id the path builder refuses must be reported, not thrown" + + assert "onClick={() => openWorkspace(workspace)}" in tab + assert "aria-label={`Open ${workspace.name || 'Untitled'}`}" in tab, ( + "Open must be named for its workspace, as the directories' Open is" + ) + # Open waits for an in-flight Set active or reconcile like Set active does, so two + # activations can never race. + assert tab.count("disabled={activating !== null || needsReconciliation}") == 2, ( + "Open and Set active must share the same busy gating" + ) + + assert_app_version_at_least("0.261.294") + print("Settings Open action test passed!") + return True + + def test_feedback_and_violations_read_the_right_fields(): """Both tabs are paginated, filtered and exported server-side.""" print("Testing feedback and violations contracts...") @@ -251,6 +316,7 @@ def test_version_is_at_least_implementation_version(): tests = [ test_workspace_lists_match_their_routes, test_set_active_uses_the_dedicated_routes, + test_rows_open_their_workspace_without_activating, test_feedback_and_violations_read_the_right_fields, test_stats_uses_an_allowed_window_and_local_charts, test_every_registered_tab_has_a_component, diff --git a/ui_tests/test_v2_settings_workspace_open.py b/ui_tests/test_v2_settings_workspace_open.py new file mode 100644 index 000000000..802988e9b --- /dev/null +++ b/ui_tests/test_v2_settings_workspace_open.py @@ -0,0 +1,172 @@ +# test_v2_settings_workspace_open.py +""" +Production-SPA coverage for opening a workspace from V2 User Settings. +Version: 0.261.294 +Implemented in: 0.261.294 + +The Groups and Public workspaces tabs in User Settings list the workspaces a user can use. Each row +offers Open beside Set active (or the Active badge). Open goes to the workspace's V2 page by its +immutable id and, like the directories' Open, never activates anything from the settings tab: a +group page activates the group it shows, and a public workspace page leaves the active public +workspace alone. + +The real built SPA runs against the closed group and public directory fixtures, so a row that +activated before navigating, opened the wrong id, or pushed Open out of the row at a phone width +fails the run. + +Run: build application/v2_ui, then python -m pytest .\\ui_tests\\test_v2_settings_workspace_open.py -q +with PLAYWRIGHT_SERVICE_URL='' and PYTHONPATH including ui_tests\\fixtures. +""" + +import pytest +from playwright.sync_api import expect + +from ui_tests.fixtures.group_workspace import connect_options, group_ui # noqa: F401 +from ui_tests.fixtures.public_directory import ( # noqa: F401 + LONG_WORKSPACE_NAME, MEMBER_WORKSPACE, MEMBER_WORKSPACE_NAME, public_directory_ui, +) +from ui_tests.fixtures.workspace_authoring import ORIGIN + + +pytestmark = pytest.mark.ui + +LAYOUTS = [ + pytest.param("light", 1440, 900, id="desktop-light"), + pytest.param("dark", 1440, 900, id="desktop-dark"), + pytest.param("light", 390, 844, id="mobile-light"), + pytest.param("dark", 390, 844, id="mobile-dark"), +] + +GROUP_SET_ACTIVE = "/api/groups/setActive" +PUBLIC_SET_ACTIVE = "/api/public_workspaces/setActive" + + +def row(ui, name): + return ui.page.get_by_role("listitem").filter(has_text=name) + + +def search_public(ui, term): + ui.page.get_by_role("textbox", name="Search public workspaces", exact=True).fill(term) + + +def requests_to(ui, path): + return [entry for entry in ui.requests if entry.path == path] + + +def assert_within(container, element): + """The element is drawn wholly inside its container, so nothing clips or pushes it out.""" + outer, inner = container.bounding_box(), element.bounding_box() + assert outer and inner, "Both the row and its Open button must be rendered." + assert outer["x"] - 0.5 <= inner["x"], "Open starts outside its row." + assert inner["x"] + inner["width"] <= outer["x"] + outer["width"] + 0.5, ( + "Open is pushed past the edge of its row." + ) + + +def assert_single_line(locator, what): + """A short label keeps its one line rather than being squeezed into a column of letters.""" + single = locator.evaluate( + """element => { + const style = getComputedStyle(element); + const line = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.6; + return element.getBoundingClientRect().height < 2 * line; + }""" + ) + assert single, f"{what} wraps onto more than one line." + + +def assert_not_truncated(locator, what): + fits = locator.evaluate("element => element.scrollWidth <= element.clientWidth") + assert fits, f"{what} is cut short to make room for the row's actions." + + +# -------------------------------------------------------------------------- +# Groups. +# -------------------------------------------------------------------------- + +def test_open_goes_to_a_group_and_the_group_page_activates_it(group_ui): + """Open on a group that isn't active lands on its page, which then makes it the active group.""" + ui = group_ui + ui.active_group = "group-b" + ui.open("/settings?tab=groups") + research = row(ui, "Research group") + expect(research.get_by_role("button", name="Set active", exact=True)).to_be_visible() + research.get_by_role("button", name="Open Research group", exact=True).click() + expect(ui.page).to_have_url(f"{ORIGIN}/v2/groups/group-a") + expect(ui.page.get_by_text("Research group owner · owner@example.test", exact=True)).to_be_visible() + expect(ui.page.get_by_text("Role: Owner", exact=True)).to_be_visible() + activations = [entry.body for entry in requests_to(ui, GROUP_SET_ACTIVE)] + assert activations == [{"groupId": "group-a"}], ( + "Opening a group should activate it exactly once, through the group page." + ) + assert ui.active_group == "group-a" + + +def test_open_on_the_active_group_makes_no_activation_request(group_ui): + """The active row offers Open too, and opening from settings never asks to activate anything.""" + ui = group_ui + ui.active_group = "group-a" + ui.open("/settings?tab=groups") + research = row(ui, "Research group") + expect(research.get_by_text("Active", exact=True)).to_be_visible() + research.get_by_role("button", name="Open Research group", exact=True).click() + expect(ui.page).to_have_url(f"{ORIGIN}/v2/groups/group-a") + expect(ui.page.get_by_text("Research group owner · owner@example.test", exact=True)).to_be_visible() + assert not requests_to(ui, GROUP_SET_ACTIVE), "Open must not activate a group from the settings tab." + + +@pytest.mark.parametrize("theme,width,height", LAYOUTS) +def test_group_rows_fit_open_beside_set_active_and_active(group_ui, theme, width, height): + """Open sits inside every row, next to the Active badge or Set active, at both widths and themes. + + On a phone the actions wrap below the name, so the name and role badge keep their room instead + of being squeezed to a few letters. + """ + ui = group_ui + ui.active_group = "group-a" + ui.open("/settings?tab=groups", theme=theme, width=width, height=height) + active, inactive = row(ui, "Research group"), row(ui, "Read-only group") + expect(active.get_by_text("Active", exact=True)).to_be_visible() + expect(inactive.get_by_role("button", name="Set active", exact=True)).to_be_visible() + for item, name, role in ((active, "Research group", "Owner"), (inactive, "Read-only group", "User")): + button = item.get_by_role("button", name=f"Open {name}", exact=True) + expect(button).to_be_visible() + expect(button).to_be_enabled() + assert_within(item, button) + assert_single_line(item.get_by_text(role, exact=True), f"The {role} badge") + assert_not_truncated(item.get_by_text(name, exact=True), f"The name {name!r}") + ui.assert_no_overflow() + + +# -------------------------------------------------------------------------- +# Public workspaces. +# -------------------------------------------------------------------------- + +def test_open_goes_to_a_public_workspace_without_activating_it(public_directory_ui): + """Open lands on the public workspace page by id and leaves the active public workspace alone.""" + ui = public_directory_ui + ui.open("/settings?tab=public") + search_public(ui, MEMBER_WORKSPACE_NAME) + library = row(ui, MEMBER_WORKSPACE_NAME) + expect(library.get_by_role("button", name="Set active", exact=True)).to_be_visible() + library.get_by_role("button", name=f"Open {MEMBER_WORKSPACE_NAME}", exact=True).click() + expect(ui.page).to_have_url(f"{ORIGIN}/v2/public/{MEMBER_WORKSPACE}") + expect(ui.page.get_by_text("About this workspace", exact=True)).to_be_visible() + assert not requests_to(ui, PUBLIC_SET_ACTIVE), ( + "Opening a public workspace from settings must not change the active public workspace." + ) + assert ui.active_workspace is None + + +@pytest.mark.parametrize("theme,width,height", LAYOUTS) +def test_a_long_public_workspace_name_leaves_room_for_open(public_directory_ui, theme, width, height): + """An 80-character name truncates rather than pushing Open or Set active out of the row.""" + ui = public_directory_ui + ui.open("/settings?tab=public", theme=theme, width=width, height=height) + search_public(ui, "Long content workspace") + long_row = row(ui, "Long content workspace") + button = long_row.get_by_role("button", name=f"Open {LONG_WORKSPACE_NAME}", exact=True) + expect(button).to_be_visible() + assert_within(long_row, button) + assert_within(long_row, long_row.get_by_role("button", name="Set active", exact=True)) + ui.assert_no_overflow()