From 38bfb197398a4c86c4c263edc1e9ca516125c33f Mon Sep 17 00:00:00 2001 From: Pavel Date: Tue, 29 Sep 2026 11:11:33 +0400 Subject: [PATCH] Frontend fixes --- docsgpt/api/user/sources/routes.py | 11 +- frontend/DESIGN.md | 110 ++++++++++++++--- frontend/src/agents/AgentCard.test.tsx | 25 ++++ frontend/src/agents/AgentCard.tsx | 19 ++- frontend/src/agents/AgentRouteGuard.test.tsx | 7 +- frontend/src/agents/AgentRouteGuard.tsx | 6 +- frontend/src/agents/NewAgent.tsx | 3 - .../components/GuardrailsSection.test.tsx | 7 +- .../agents/components/GuardrailsSection.tsx | 17 ++- .../src/agents/workflow/WorkflowBuilder.tsx | 3 +- frontend/src/components/ConfigFields.test.tsx | 64 ++++++++++ frontend/src/components/ConfigFields.tsx | 8 +- frontend/src/components/RoleBadge.test.tsx | 52 ++++++++ frontend/src/components/RoleBadge.tsx | 29 +++++ .../src/components/ViewOnlyNotice.test.tsx | 35 ++++++ frontend/src/components/ViewOnlyNotice.tsx | 28 +++++ frontend/src/locale/de.json | 15 +-- frontend/src/locale/en.json | 15 +-- frontend/src/locale/es.json | 15 +-- frontend/src/locale/jp.json | 15 +-- frontend/src/locale/ru.json | 15 +-- frontend/src/locale/zh-TW.json | 15 +-- frontend/src/locale/zh.json | 15 +-- .../src/modals/AgentDetailsModal.test.tsx | 3 + frontend/src/modals/AgentDetailsModal.tsx | 4 +- frontend/src/modals/MCPServerModal.test.tsx | 17 ++- frontend/src/modals/MCPServerModal.tsx | 12 +- .../src/preferences/PromptsModal.test.tsx | 43 +++++++ frontend/src/preferences/PromptsModal.tsx | 33 +++-- .../src/settings/SourceConfigModal.test.tsx | 15 ++- frontend/src/settings/SourceConfigModal.tsx | 11 +- frontend/src/settings/Sources.test.tsx | 9 ++ frontend/src/settings/Sources.tsx | 19 +-- frontend/src/settings/Teams.test.tsx | 64 +++++++++- frontend/src/settings/Teams.tsx | 116 +++++++++--------- frontend/src/settings/ToolConfig.test.tsx | 34 ++++- frontend/src/settings/ToolConfig.tsx | 5 + frontend/src/settings/Tools.test.tsx | 24 +++- frontend/src/settings/Tools.tsx | 57 ++++----- frontend/src/teams/ShareToTeamModal.test.tsx | 43 ++++++- frontend/src/teams/ShareToTeamModal.tsx | 63 +++++----- tests/api/user/sources/test_source_roles.py | 17 ++- 42 files changed, 834 insertions(+), 284 deletions(-) create mode 100644 frontend/src/components/ConfigFields.test.tsx create mode 100644 frontend/src/components/RoleBadge.test.tsx create mode 100644 frontend/src/components/RoleBadge.tsx create mode 100644 frontend/src/components/ViewOnlyNotice.test.tsx create mode 100644 frontend/src/components/ViewOnlyNotice.tsx diff --git a/docsgpt/api/user/sources/routes.py b/docsgpt/api/user/sources/routes.py index abbb0492..fab6968a 100644 --- a/docsgpt/api/user/sources/routes.py +++ b/docsgpt/api/user/sources/routes.py @@ -77,9 +77,10 @@ def _get_provider_from_remote_data(remote_data): def _with_access(entry: dict, access: Optional[str], switches: Optional[dict]) -> dict: """Add ``access`` + ``allowed_actions`` to a listed source row. - The source's behaviour ``config`` is dropped when the caller's role may - not ``view_config`` (a viewer when the owner turned - ``viewers_can_see_config`` off). + The source's behaviour ``config`` is cut down to its ``kind`` when the + caller's role may not ``view_config`` (a viewer when the owner turned + ``viewers_can_see_config`` off). ``kind`` stays because the UI needs it to + pick the wiki / graph view. Args: entry: The row as the list endpoint builds it. @@ -91,7 +92,9 @@ def _with_access(entry: dict, access: Optional[str], switches: Optional[dict]) - """ payload = payload_for("source", access, switches) if "view_config" not in payload["allowed_actions"]: - entry.pop("config", None) + config = entry.get("config") + if config is not None: + entry["config"] = {"kind": (config or {}).get("kind", "classic")} entry.update(payload) return entry diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 4e918b93..82c412cb 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -17,7 +17,8 @@ from the Tailwind scale, never from arbitrary `[...]` values. Runtime values go in CSS custom properties or, when unavoidable, an inline style with a disable comment. Conditional classes go through `cn(...)`, never a template literal (prettier sorts the classes inside `cn`, and `cn` merges conflicts). -Every user-visible string, attributes included (`aria-label`, IconButton +What a role may do is gated with `can(item, action)` and a refused action is +not rendered (see Access and roles). Every user-visible string, attributes included (`aria-label`, IconButton `label`, `placeholder`, `title`, `alt`, `hint`), is a `t()` key in all seven locales (`de en es jp ru zh zh-TW`); admin pages are English by design. Interpolated user text (a name, a timezone, a date) passes `interpolation: { @@ -228,13 +229,14 @@ pill`, the only `outline-primary` on the agent pages themselves (the Access `outline field pill` (Save draft, Preview with `Play` first), Cancel `ghost field pill`, and page-level actions go in the `ActionMenu` (`size="toolbar"`) at the end of the title row, through `SectionShell titleAction`: Access - details and Share with team on Overview, and Preview until the agent is + details and Share with team on Overview (each only when the role allows + it, see Access and roles), and Preview until the agent is published (before that the preview only says "Publish to preview"). So the row never holds more than three buttons and fits a phone, where the main one stretches across it (`flex-1 sm:flex-none`; `PageToolbar`'s action slot spans the stacked row below `sm`). The workflow builder's toolbar has no title row and keeps its ⋯ at the row's end, holding Edit details, Access - details and Delete (see Workflow builder). The + details, Share with team and Delete (see Workflow builder). The agent's Delete is in Overview's danger zone; only the workflow builder's toolbar has it in the ⋯ menu, as a `destructive` item. The row itself is `agents/components/AgentPageToolbar` (see Page chrome). A row's common @@ -294,7 +296,10 @@ justify-start` and `aria-expanded`: a lucide `ChevronRight` first (`has-[[data-variant=section-toggle]:focus-visible]:ring-3 … ring-inset`, inset because a scrolling column clips an outset ring), so pages pass nothing for it. Status badges go beside the button, not inside it, or the - hover underline runs under them. + hover underline runs under them. Because the ring comes from the Card, + `section-toggle` only goes inside a Card: a collapsible group in a Modal + or drawer (Share's Access settings) is the inline `link sm` disclosure + toggle above. - Drop `text-white` on default and destructive buttons; the foreground token already provides it. Drop `disabled:cursor-not-allowed` on buttons; the base disables pointer events. Fields are the other way round (see Focus, @@ -466,7 +471,10 @@ pill, including schedule and run status pills (`agents/schedules/StatusBadge.tsx maps schedule and run statuses to Badge variants), trace chips and statuses, token scope chips, "Disabled" and tool chips. A grey chip is `neutral`, never a `bg-muted` pill. The admin role is `default` wherever it shows (Teams, Admin -→ Users); every other role is `neutral`. Chips that show code (token scopes) pass `font-mono`, and +→ Users); every other role is `neutral`. A shared asset's tile (agent, +source, tool, prompt) shows the caller's role with `components/RoleBadge`: a +`neutral` Badge with `Users` first (Editor, Viewer, from `roleOf()`); your own +assets show none. Chips that show code (token scopes) pass `font-mono`, and stat chips `tabular-nums`, as approved exceptions. HTTP method pills take their variant from `getMethodBadgeVariant` (`utils/httpMethodColors.ts`): GET `success`, POST `info`, PUT `warning`, DELETE `destructive`, PATCH `default`, anything else @@ -603,6 +611,13 @@ state assignment) is not a bare row: each of its fields has a floating label. Never hand-build a label above a field, a hand-positioned floating label, or a `div.flex-col` + `Label` + `

` stack. +A saved secret (an API key, a tool's credential, a custom header value) never +comes back from the API, for any role. Its field is empty and `type="password"`; +in a FormField the saved state is the `hint` ("Saved. Leave empty to keep +it."), because a placeholder is hidden while the label rests. Only a +label-less table cell carries it as the placeholder +(`settings.tools.savedSecretPlaceholder`). Never a `••••` placeholder. + ### SettingRow (`ui/setting-row.tsx`) A setting with a control on the right (a Switch, a short Input) is @@ -632,7 +647,10 @@ description use a Switch in a SettingRow instead. ### Switch, TimePicker and Calendar (`ui/switch.tsx`, `ui/time-picker.tsx`, `ui/calendar.tsx`) `Switch` (Radix) is an on/off setting, placed inside a `SettingRow` that names -it. The track is `primary` when on and `bg-input` when off, with a white +it. A tile's own on/off (the tool tile's "In my chats") is the one exception: +a `Label text-muted-foreground text-xs font-normal` + `Switch` pair at the +tile's bottom-right. For a shared tool it is the caller's own preference, +never a switch that turns the tool off for everyone. The track is `primary` when on and `bg-input` when off, with a white thumb in both themes (see Elevation). `TimePicker` picks a time of day as two `SelectTrigger`s, hours and minutes, @@ -680,6 +698,11 @@ row (the Agents filter pills) are not a ToggleGroup: they are `Button asChild variant={active ? 'outline' : 'ghost-muted'} size="sm" shape="pill"` around each `Link`, with `aria-current="page"` on the current one. +Filtering a list by kind (a team's shared resources, Share's People) is this +`xs` group in its muted track, each item `{label} {formatCount(n)}`, beside a +`SearchInput size="sm"` (`w-full sm:w-56`); no match is `EmptyState size="xs" +illustration="none"`. + ### Separator (`ui/separator.tsx`) A 1px `bg-border` rule, horizontal or `orientation="vertical"`, decorative @@ -717,7 +740,9 @@ height of its own). `label` puts a muted caption under the ring (a long job: Convert to wiki, Enable GraphRAG); it is also the ring's name. `size` takes the Spinner sizes (`default` unless set): `sm` inside a picker (the MultiSelect popover's list), `lg` for a full panel (a remote device's -config). Spinners +config). A role guard shows it while it resolves, then redirects +silently: `AdminRoute` at `fill="screen"`, `AgentRouteGuard` at +`fill="parent"` (it renders inside the app shell's scroll column) (see Access and roles). Spinners inside a control (a busy Button, a picker's `sm` ring) stay `Spinner`. Nothing to show is `EmptyState`: `size` `default | sm | xs` (128 / 96 / 64px @@ -838,6 +863,43 @@ variant="destructive"` above the form, not text in or beside the button. publish validation) is a destructive `Alert` floating at `z-20` on an opaque `bg-card rounded-xl shadow-md` wrapper that stays until closed; a toast would truncate each error to one line and dismiss itself. +- A switch moves at once and flips back when the server refuses, with a + destructive toast (an Alert inside a modal). A delete removes its row only + after the server confirms; a 403 shows `errors.forbidden`. + +### Access and roles + +Agents, sources, tools and prompts come to the UI with `access` and +`allowed_actions`. Gate every control with `can(item, action)` +(`utils/accessUtils`; `canAgent` in `agents/agentAccess.ts` for agents, which +also refuses Logs, Schedules and Pin on a draft), never on `ownership`, +`team_access` or the user id. + +- An action the role doesn't allow is not rendered. A menu builds its + options from `can()`, and a ⋯ with no options is not drawn; a footer or + row left empty goes, with its `Separator`. Never a disabled Save or a + disabled menu item for a role. +- A control stays visible but `disabled` only when it shows state the caller + should still see (a switch's on/off, a policy's value). Then the reason is + on screen (an Alert or a FormField `hint`), never a bare grey control. A + control that can't apply to the caller at all (a tool's "In my chats" when + the grant doesn't allow it in their chats) is hidden, not disabled. +- A view-only form opens with the same fields and `ViewOnlyNotice` + (`components/ViewOnlyNotice`: an `Alert role="note"` with `Lock` first and + `common.viewOnlyNotice`) as its first child. Where only part of an editable + form is locked (a tool's credentials when the owner turned off "Editors can + change credentials"), the same notice passes its own `message`. There is no Save; Cancel becomes a lone Close. + Fields are `disabled` (a group: `

`); + long text the user reads or copies (a prompt, a chunk) is `readOnly`, so it + keeps full contrast and scrolls. Titles and menu items swap Edit and + `Pencil` for View and `Eye`. +- Source views follow the same rule: every write (Add file, Sync, Add chunk, + Edit, Delete) shows only with `can(source, 'edit')`; reading, copying and + paging stay. +- A page the role can't open is refused twice: its tabs and section items + are filtered by the same action (`AgentPageHeader`, `navigation/sections`), + and its route is wrapped in a guard (`AgentRouteGuard`) that redirects to + the list as a deep-link fallback, with `LoadingState` while it resolves. ### Alert (`ui/alert.tsx`) @@ -892,8 +954,7 @@ bordered list for one). Rows are `px-4 py-3`, the title `text-sm font-medium`. `interactive` (with `asChild` around a `` or ` - {callerCanShare && ( +
- )} + {callerCanShare && ( + + )} +
-
+
{ownerLabel(openResource)} @@ -1337,9 +1341,9 @@ export default function Teams() { /> )} {line.text} diff --git a/frontend/src/settings/ToolConfig.test.tsx b/frontend/src/settings/ToolConfig.test.tsx index f747f2c9..c91f233c 100644 --- a/frontend/src/settings/ToolConfig.test.tsx +++ b/frontend/src/settings/ToolConfig.test.tsx @@ -426,11 +426,17 @@ describe('ToolConfig', () => { it('opens read-only without edit or edit_credentials: no Save, every field disabled', async () => { await render({ ...configTool, ...viewer } as UserToolType); expect(buttonByText('settings.tools.save')).toBeUndefined(); + const note = container.querySelector('[data-slot="alert"]'); + expect(note?.getAttribute('role')).toBe('note'); + expect(note?.textContent).toBe('common.viewOnlyNotice'); expect(nameInput().disabled).toBe(true); - const secret = Array.from( - container.querySelectorAll('input'), - ).find((i) => i.placeholder === '••••••••'); + const secret = container.querySelector( + 'input[type="password"]', + ); expect(secret && disabled(secret)).toBe(true); + expect(secret?.value).toBe(''); + expect(secret?.placeholder).not.toContain('•'); + expect(container.textContent).toContain('common.savedSecretHint'); container .querySelectorAll('[role="switch"]') .forEach((sw) => expect(disabled(sw)).toBe(true)); @@ -461,13 +467,33 @@ describe('ToolConfig', () => { it('lets an editor without edit_credentials rename but not touch credentials', async () => { await render({ ...configTool, ...editorNoCreds } as UserToolType); expect(nameInput().disabled).toBe(false); + // Not a view-only form: the editor can still save a rename. + expect(container.textContent).not.toContain('common.viewOnlyNotice'); const authInputs = Array.from( container.querySelectorAll('input'), ).filter((i) => i !== nameInput() && !i.closest('table')); - const credential = authInputs.find((i) => i.placeholder === '••••••••'); + const credential = authInputs.find((i) => i.type === 'password'); expect(credential && disabled(credential)).toBe(true); }); + it('says why the credentials are locked for an editor without edit_credentials', async () => { + await render({ ...configTool, ...editorNoCreds } as UserToolType); + const note = container.querySelector('[data-slot="alert"]'); + expect(note?.getAttribute('role')).toBe('note'); + expect(note?.textContent).toBe('common.credentialsLockedNotice'); + }); + + it('shows no credentials note to a role that may change them', async () => { + await render({ + ...configTool, + access: 'editor', + allowed_actions: ['edit', 'edit_credentials', 'use'], + } as UserToolType); + expect(container.textContent).not.toContain( + 'common.credentialsLockedNotice', + ); + }); + it("disables an API tool's URL and header values without edit_credentials", async () => { await render({ ...apiTool, ...editorNoCreds } as APIToolType); await expandFirstAction(); diff --git a/frontend/src/settings/ToolConfig.tsx b/frontend/src/settings/ToolConfig.tsx index b8d8d628..fc7598d2 100644 --- a/frontend/src/settings/ToolConfig.tsx +++ b/frontend/src/settings/ToolConfig.tsx @@ -5,6 +5,7 @@ import { useSelector } from 'react-redux'; import userService from '../api/services/userService'; import ConfigFields from '../components/ConfigFields'; +import ViewOnlyNotice from '../components/ViewOnlyNotice'; import SearchInput from '../components/SearchInput'; import { Alert, AlertDescription } from '../components/ui/alert'; import { @@ -395,6 +396,10 @@ export default function ToolConfig({ )}
+ {readOnly && } + {!readOnly && !canEditCredentials && ( + + )} {saveError && ( {saveError} diff --git a/frontend/src/settings/Tools.test.tsx b/frontend/src/settings/Tools.test.tsx index caf80038..141d8b42 100644 --- a/frontend/src/settings/Tools.test.tsx +++ b/frontend/src/settings/Tools.test.tsx @@ -229,13 +229,25 @@ describe('Tools', () => { ); }); - it('disables the switch, keeping its label, for a shared tool without use_in_own', async () => { + // The tool can't be in the caller's chats at all (the composer picker + // hides it too), so there is no state to show: no switch, no bare grey one. + it('hides the switch and its label for a shared tool without use_in_own', async () => { await render([viewerTool]); - const sw = switchOf('vw'); - expect(sw.disabled).toBe(true); - expect(card('vw').querySelector(`label[for="${sw.id}"]`)?.textContent).toBe( - 'settings.tools.inMyChats', - ); + expect(card('vw').querySelector('[role="switch"]')).toBeNull(); + expect(card('vw').textContent).not.toContain('settings.tools.inMyChats'); + }); + + it('shows the role on a shared tile as a neutral Users Badge', async () => { + await render([ownTool, editorTool, viewerTool]); + const badge = (id: string) => + card(id).querySelector('[data-testid="role-badge"]'); + expect(badge('own')).toBeNull(); + expect(badge('ed')?.dataset.variant).toBe('neutral'); + expect(badge('ed')?.textContent).toBe('teamAccess.editor'); + expect(badge('vw')?.textContent).toBe('teamAccess.viewer'); + expect( + badge('ed')?.querySelector('svg')?.getAttribute('class'), + ).not.toContain('size-3'); }); it('reverts the switch and shows an error toast when the update fails', async () => { diff --git a/frontend/src/settings/Tools.tsx b/frontend/src/settings/Tools.tsx index 4f3d62fa..6e8a71b6 100644 --- a/frontend/src/settings/Tools.tsx +++ b/frontend/src/settings/Tools.tsx @@ -7,6 +7,7 @@ import devicesService from '../api/services/devicesService'; import userService from '../api/services/userService'; import PageToolbar from '../components/PageToolbar'; import SearchInput from '../components/SearchInput'; +import RoleBadge from '../components/RoleBadge'; import SkeletonLoader from '../components/SkeletonLoader'; import ToolIcon from '../components/ToolIcon'; import { Badge } from '../components/ui/badge'; @@ -24,7 +25,7 @@ import { ActiveState } from '../models/misc'; import { showActionToast } from '../notifications/actionToastSlice'; import { selectToken } from '../preferences/preferenceSlice'; import ShareToTeamModal from '../teams/ShareToTeamModal'; -import { can, isOwner } from '../utils/accessUtils'; +import { can, isOwner, roleOf } from '../utils/accessUtils'; import { canAddToolToOwn, toolInChat } from '../utils/toolUtils'; import RemoteDeviceConfig from './RemoteDeviceConfig'; import ToolConfig from './ToolConfig'; @@ -116,7 +117,7 @@ export default function Tools() { timeout: config.timeout || 30, oauth_scopes: oauthScopes, has_encrypted_credentials: !!config.has_encrypted_credentials, - access: tool.access ?? (isOwner(tool) ? 'owner' : tool.team_access), + access: roleOf(tool), owner_label: tool.owner_label ?? null, }); setReconnectModalState('ACTIVE'); @@ -395,14 +396,7 @@ export default function Tools() { )} )} - {tool.ownership === 'team' && ( - - - )} +
-
- - - updateToolStatus(tool.id, checked) - } - disabled={!canAddToolToOwn(tool)} - id={`toolToggle-${index}`} - aria-label={t('settings.tools.useInMyChatsAria', { - interpolation: { escapeValue: false }, - toolName: tool.customName || tool.displayName, - })} - /> -
+ {/* A shared tool without use_in_own can't be in the + caller's chats at all, so there is no switch. */} + {canAddToolToOwn(tool) && ( +
+ + + updateToolStatus(tool.id, checked) + } + id={`toolToggle-${index}`} + aria-label={t('settings.tools.useInMyChatsAria', { + interpolation: { escapeValue: false }, + toolName: tool.customName || tool.displayName, + })} + /> +
+ )} ))} diff --git a/frontend/src/teams/ShareToTeamModal.test.tsx b/frontend/src/teams/ShareToTeamModal.test.tsx index aa5117ee..ef6d30ac 100644 --- a/frontend/src/teams/ShareToTeamModal.test.tsx +++ b/frontend/src/teams/ShareToTeamModal.test.tsx @@ -47,6 +47,12 @@ vi.mock('../api/services/teamsService', () => ({ }, })); +// Mark formatted counts so a raw number in the UI shows up in a test. +vi.mock('../utils/dateTimeUtils', async (importOriginal) => ({ + ...(await importOriginal()), + formatCount: (value: number) => `#${value}`, +})); + import ShareToTeamModal from './ShareToTeamModal'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -131,7 +137,11 @@ describe('ShareToTeamModal', () => { const toggle = buttonByText('settings.teams.accessSettings.title'); expect(toggle).toBeDefined(); expect(toggle?.getAttribute('aria-expanded')).toBe('false'); - expect(toggle?.getAttribute('data-variant')).toBe('section-toggle'); + // An inline disclosure (no Card around it to draw section-toggle's + // ring), so the button shows its own keyboard focus. + expect(toggle?.getAttribute('data-variant')).toBe('link'); + expect(toggle?.className).toContain('focus-visible:ring-3'); + expect(toggle?.className).not.toContain('focus-visible:ring-0'); expect(body().querySelectorAll('[role="switch"]')).toHaveLength(0); act(() => toggle!.click()); expect(body().querySelectorAll('[role="switch"]')).toHaveLength(4); @@ -249,8 +259,11 @@ describe('ShareToTeamModal', () => { expect(rows).toHaveLength(4); // Most recent first: user-7, user-6, user-5. expect(rows[1].textContent).toContain('user-7'); - expect(text()).toContain('settings.teams.share.andMore(count=5)'); + expect(text()).toContain('settings.teams.share.andMore(count=#5)'); const showAll = buttonByText('settings.teams.share.showAll'); + expect(showAll?.textContent).toContain( + 'settings.teams.share.showAll(count=#8)', + ); expect(showAll?.getAttribute('data-variant')).toBe('link'); expect(body().querySelector('.max-h-72')).toBeNull(); }); @@ -260,11 +273,21 @@ describe('ShareToTeamModal', () => { await render(); act(() => buttonByText('settings.teams.share.showAll')!.click()); expect(text()).toContain( - 'settings.teams.share.allSummary(name=QBR Report Builder,teams=2,people=6)', + 'settings.teams.share.allSummary(name=QBR Report Builder,teams=#2,people=#6)', ); expect(buttonByText('settings.teams.share.back')).toBeDefined(); // You + all 8. expect(body().querySelectorAll('[data-slot="list-row"]')).toHaveLength(9); + expect( + Array.from(body().querySelectorAll('[role="radio"]')).map( + (p) => p.textContent, + ), + ).toEqual([ + 'settings.teams.share.filter.all #8', + 'settings.teams.share.filter.teams #2', + 'settings.teams.share.filter.people #6', + 'settings.teams.share.filter.editors #2', + ]); const teamsPill = Array.from( body().querySelectorAll('[role="radio"]'), @@ -294,6 +317,20 @@ describe('ShareToTeamModal', () => { expect(rows).toHaveLength(1); expect(rows[0].textContent).toContain('user-6'); + // No match: the "no results" EmptyState line, not a bare paragraph. + act(() => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!; + setter.call(search, 'nobody-here'); + search!.dispatchEvent(new Event('input', { bubbles: true })); + }); + const empty = body().querySelector('[data-slot="empty-state"]'); + expect(empty?.getAttribute('data-size')).toBe('xs'); + expect(empty?.querySelector('img')).toBeNull(); + expect(empty?.textContent).toContain('settings.teams.share.noMatches'); + act(() => buttonByText('settings.teams.share.back')!.click()); expect(buttonByText('settings.teams.share.showAll')).toBeDefined(); }); diff --git a/frontend/src/teams/ShareToTeamModal.tsx b/frontend/src/teams/ShareToTeamModal.tsx index dc464a59..ac875b3c 100644 --- a/frontend/src/teams/ShareToTeamModal.tsx +++ b/frontend/src/teams/ShareToTeamModal.tsx @@ -21,6 +21,7 @@ import SearchInput from '../components/SearchInput'; import { Alert, AlertDescription } from '../components/ui/alert'; import { Avatar } from '../components/ui/avatar'; import { Button } from '../components/ui/button'; +import { EmptyState } from '../components/ui/empty-state'; import { Command, CommandEmpty, @@ -52,6 +53,7 @@ import { cn } from '../lib/utils'; import { selectToken } from '../preferences/preferenceSlice'; import { AppDispatch } from '../store'; import { can } from '../utils/accessUtils'; +import { formatCount } from '../utils/dateTimeUtils'; import { decodeJwtPayload } from '../utils/jwtUtils'; import { anyChanged, @@ -688,8 +690,8 @@ export default function ShareToTeamModal({ {t('settings.teams.share.allSummary', { interpolation: { escapeValue: false }, name: resourceName ?? '', - teams: teamGrantCount, - people: personGrantCount, + teams: formatCount(teamGrantCount), + people: formatCount(personGrantCount), })}

@@ -712,7 +714,8 @@ export default function ShareToTeamModal({ > {filterOptions.map((option) => ( - {t(`settings.teams.share.filter.${option.value}`)} {option.count} + {t(`settings.teams.share.filter.${option.value}`)}{' '} + {formatCount(option.count)} ))} @@ -722,36 +725,36 @@ export default function ShareToTeamModal({ {filteredShares.map(renderShareRow)} {filteredShares.length === 0 && ( -

- {t('settings.teams.share.noMatches')} -

+ )} ); const accessSettings = canManageSettings && (
-

- -

+ {/* An inline disclosure: no Card around it draws section-toggle's + ring, so the link button shows its own focus. */} + {settingsOpen && ( {settings.map((setting) => { @@ -893,7 +896,7 @@ export default function ShareToTeamModal({ onValueChange={(value) => setAccessLevel(value as AccessLevel)} > @@ -931,7 +934,7 @@ export default function ShareToTeamModal({ onClick={openAllStep} > {t('settings.teams.share.showAll', { - count: shares.length, + count: formatCount(shares.length), })} @@ -945,7 +948,7 @@ export default function ShareToTeamModal({ {isLongList && (

{t('settings.teams.share.andMore', { - count: shares.length - previewShares.length, + count: formatCount(shares.length - previewShares.length), })}

)} diff --git a/tests/api/user/sources/test_source_roles.py b/tests/api/user/sources/test_source_roles.py index c4a2282a..ed5ec259 100644 --- a/tests/api/user/sources/test_source_roles.py +++ b/tests/api/user/sources/test_source_roles.py @@ -134,10 +134,21 @@ class TestListPayload: v = next(r for r in viewer.json if r["id"] == sid) e = next(r for r in editor.json if r["id"] == sid) vp = next(r for r in vpage.json["paginated"] if r["id"] == sid) - assert "config" not in v + # Only the behaviour selector survives: the UI needs it to pick the view. + assert v["config"] == {"kind": "classic"} assert v["allowed_actions"] == ["use"] - assert "config" not in vp - assert "config" in e + assert vp["config"] == {"kind": "classic"} + assert "retrieval" in e["config"] + + def test_viewer_keeps_graphrag_kind_when_config_hidden(self, app, pg_conn): + from docsgpt.api.user.sources.routes import CombinedJson + + sid = _shared_source(pg_conn, config={"kind": "graphrag"}) + _set(pg_conn, sid, viewers_can_see_config=False) + with _patch_db(pg_conn, ROUTES): + viewer = _call(app, VIEWER, "/api/sources", CombinedJson().get) + v = next(r for r in viewer.json if r["id"] == sid) + assert v["config"] == {"kind": "graphrag"} def test_paginated_rows_carry_access(self, app, pg_conn): from docsgpt.api.user.sources.routes import PaginatedSources