From a76d2a0ff8ccfcf6de1fe517be2af526b8f784aa Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 6 Oct 2026 15:58:07 -0400 Subject: [PATCH] Merge V2 File Sync settings into one card with nested workspace types - Fold the four File Sync sub-sections into file-sync-section. The Personal, Group and Public workspace switches nest under Enable File Sync, each type's access rules sit in an Access panel anchored beneath its own switch, and Visible Source Types becomes a "Source types" panel beside Run limits. - Add an optional group `anchor` descriptor. SettingsSection draws an anchored panel inside its switch's row, falls back to the card body with the switch's name when a search hides the switch, and gives each panel an accessible name. A closed group holding one choice list reports "N selected". - Drop the four sub-sections from ADMIN_NAV and point the PersonalFileSyncUser roster entry at file-sync-section. The classic pane markup is unchanged. - Cover the structure in the File Sync schema test, anchor validation in the vocabulary test, placement and summaries in the TS logic checks, and the built card in a new Playwright UI test. Rewrite the Knowledge File Sync docs, regenerate the app surface inventory, and bump to 0.261.260. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/admin_app_roles.py | 2 +- .../single_app/admin_settings_fields.py | 351 +++++++++++------- application/single_app/admin_settings_nav.py | 4 - application/single_app/config.py | 2 +- .../src/components/admin/SettingsSection.tsx | 92 ++++- application/v2_ui/src/lib/adminFields.ts | 12 + application/v2_ui/src/lib/adminSections.ts | 73 ++++ application/v2_ui/src/styles/theme.css | 7 + docs/_data/app_surface.yml | 18 +- docs/admin/knowledge.md | 122 +++--- .../V2_ADMIN_FILE_SYNC_SINGLE_CARD.md | 158 ++++++++ .../features/V2_ADMIN_KNOWLEDGE_SETTINGS.md | 11 +- .../V2_ADMIN_SETTINGS_LAYOUT_AND_HIERARCHY.md | 11 +- .../test_v2_admin_knowledge_file_sync.py | 280 +++++++++++--- .../test_v2_admin_schema_vocabulary.py | 91 ++++- .../test_v2_admin_section_logic.ts | 225 ++++++++++- .../test_v2_admin_file_sync_single_card.py | 196 ++++++++++ 17 files changed, 1371 insertions(+), 284 deletions(-) create mode 100644 docs/explanation/features/V2_ADMIN_FILE_SYNC_SINGLE_CARD.md create mode 100644 ui_tests/test_v2_admin_file_sync_single_card.py diff --git a/application/single_app/admin_app_roles.py b/application/single_app/admin_app_roles.py index 9ac16fafc..eb4c1032e 100644 --- a/application/single_app/admin_app_roles.py +++ b/application/single_app/admin_app_roles.py @@ -137,7 +137,7 @@ "key": "file_sync_personal_require_app_role", "role": "PersonalFileSyncUser", "label": "Personal workspace file sync", - "section_id": "file-sync-personal-section", + "section_id": "file-sync-section", "grants": "Syncing files into a personal workspace.", "when_off": "Any signed-in user can set up personal workspace sync.", "depends_on": "enable_file_sync", diff --git a/application/single_app/admin_settings_fields.py b/application/single_app/admin_settings_fields.py index 374c9632f..b1ebb3fc4 100644 --- a/application/single_app/admin_settings_fields.py +++ b/application/single_app/admin_settings_fields.py @@ -40,6 +40,13 @@ Intelligence is a flat run of forty controls in which the credential that makes the rest work is simply the last one. + A group may also name an ``anchor``: the key of an ungrouped switch declared + earlier in the same section. The renderer then draws the group as a panel + directly beneath that switch instead of at the foot of the card, which is + how each File Sync workspace type keeps its Access panel beside its own + toggle. Every field in an anchored group depends on the anchor, so the + panel can never show while its switch is off. + ``depends_on`` Shows a field only while a condition holds. See ``evaluate_dependency`` for the supported shapes; ``any_of`` exists because the Speech resource block is @@ -2918,8 +2925,19 @@ # `requires` descriptor: without it an administrator turns File Sync on and # nothing happens, with no visible reason until a flash message after saving. # - # The three scope sections share one shape -- enable, access, assignment -- - # so learning Personal is enough to read Group and Public. + # Everything File Sync governs is one card. A workspace type is only live + # while File Sync and its own switch are both on (functions_file_sync.py), + # so the three type switches follow the capability and depend on it, which + # is what draws them nested beneath it. Keep them contiguous: + # deriveFieldHierarchy nests an uninterrupted run, so a field declared + # between them would leave the switches after it un-nested. + # + # Each type's access rules form an Access panel anchored under that type's + # switch. Visibility is not transitive, so every field in those panels + # repeats the enable_file_sync condition; without it a panel would stay + # visible with File Sync off. The three panels share one shape -- who + # manages sources, then the type's own restriction -- so learning Personal + # is enough to read Group and Public. # ------------------------------------------------------------------ "file-sync-section": [ { @@ -2943,6 +2961,194 @@ ), }, }, + { + "key": "enable_file_sync_personal", + "type": "switch", + "label": "Personal workspaces", + "default": True, + "depends_on": {"key": "enable_file_sync", "equals": True}, + }, + { + "key": "enable_file_sync_group", + "type": "switch", + "label": "Group workspaces", + "default": True, + "depends_on": {"key": "enable_file_sync", "equals": True}, + }, + { + "key": "enable_file_sync_public", + "type": "switch", + "label": "Public workspaces", + "default": False, + "depends_on": {"key": "enable_file_sync", "equals": True}, + }, + { + "key": "file_sync_personal_admin_only", + "type": "switch", + "label": "Only administrators manage sources", + "help": "Users keep their synced documents but cannot add or edit a source.", + "default": False, + "group": { + "id": "personal-access", + "label": "Access", + "variant": "access", + "anchor": "enable_file_sync_personal", + }, + "depends_on": [ + {"key": "enable_file_sync", "equals": True}, + {"key": "enable_file_sync_personal", "equals": True}, + ], + }, + { + "key": "file_sync_personal_require_app_role", + "type": "switch", + "label": "Require the PersonalFileSyncUser app role", + "help": ( + "Required app role value: PersonalFileSyncUser. Assign it in the " + "Enterprise App before turning this on, or no user will be able to " + "manage a personal source." + ), + "default": False, + "group": { + "id": "personal-access", + "label": "Access", + "variant": "access", + "anchor": "enable_file_sync_personal", + }, + "depends_on": [ + {"key": "enable_file_sync", "equals": True}, + {"key": "enable_file_sync_personal", "equals": True}, + ], + }, + { + "key": "file_sync_group_admin_only", + "type": "switch", + "label": "Only administrators manage sources", + "default": False, + "group": { + "id": "group-access", + "label": "Access", + "variant": "access", + "anchor": "enable_file_sync_group", + }, + "depends_on": [ + {"key": "enable_file_sync", "equals": True}, + {"key": "enable_file_sync_group", "equals": True}, + ], + }, + { + "key": "require_group_assignment_for_file_sync", + "type": "switch", + "label": "Restrict to assigned groups", + "help": "Only the groups listed below may use File Sync.", + "default": False, + "group": { + "id": "group-access", + "label": "Access", + "variant": "access", + "anchor": "enable_file_sync_group", + }, + "depends_on": [ + {"key": "enable_file_sync", "equals": True}, + {"key": "enable_file_sync_group", "equals": True}, + ], + }, + { + "key": "file_sync_allowed_group_ids", + "type": "id_list", + "label": "Assigned groups", + "help": ( + "Leaving this empty while the restriction is on means no group can use " + "File Sync." + ), + "default": [], + "placeholder": "Search groups by name", + "search_endpoint": "/api/admin/file-sync/groups/search", + "search_param": "q", + "results_key": "groups", + "item_noun": "group", + "item_noun_plural": "groups", + # Group ids are canonical UUIDs, and the shared normalizer drops + # anything else, matching what the server-rendered form stores. + "id_kind": "group", + "group": { + "id": "group-access", + "label": "Access", + "variant": "access", + "anchor": "enable_file_sync_group", + }, + "depends_on": [ + {"key": "enable_file_sync", "equals": True}, + {"key": "enable_file_sync_group", "equals": True}, + {"key": "require_group_assignment_for_file_sync", "equals": True}, + ], + }, + { + "key": "file_sync_public_admin_only", + "type": "switch", + "label": "Only administrators manage sources", + "default": False, + "group": { + "id": "public-access", + "label": "Access", + "variant": "access", + "anchor": "enable_file_sync_public", + }, + "depends_on": [ + {"key": "enable_file_sync", "equals": True}, + {"key": "enable_file_sync_public", "equals": True}, + ], + }, + { + "key": "require_public_workspace_assignment_for_file_sync", + "type": "switch", + "label": "Restrict to assigned public workspaces", + "help": "Only the public workspaces listed below may use File Sync.", + "default": False, + "group": { + "id": "public-access", + "label": "Access", + "variant": "access", + "anchor": "enable_file_sync_public", + }, + "depends_on": [ + {"key": "enable_file_sync", "equals": True}, + {"key": "enable_file_sync_public", "equals": True}, + ], + }, + { + "key": "file_sync_allowed_public_workspace_ids", + "type": "id_list", + "label": "Assigned public workspaces", + "help": ( + "Leaving this empty while the restriction is on means no public " + "workspace can use File Sync." + ), + "default": [], + "placeholder": "Search public workspaces by name", + "search_endpoint": "/api/admin/file-sync/public-workspaces/search", + "search_param": "q", + "results_key": "workspaces", + "item_noun": "public workspace", + "item_noun_plural": "public workspaces", + # Public workspace ids are not UUID-constrained, so they are only + # trimmed and deduplicated. + "id_kind": "opaque", + "group": { + "id": "public-access", + "label": "Access", + "variant": "access", + "anchor": "enable_file_sync_public", + }, + "depends_on": [ + {"key": "enable_file_sync", "equals": True}, + {"key": "enable_file_sync_public", "equals": True}, + { + "key": "require_public_workspace_assignment_for_file_sync", + "equals": True, + }, + ], + }, { "key": "file_sync_max_sources_per_scope", "type": "number", @@ -3010,8 +3216,6 @@ "group": {"id": "limits", "label": "Run limits", "variant": "limits"}, "depends_on": {"key": "enable_file_sync", "equals": True}, }, - ], - "file-sync-source-types-section": [ { "key": "file_sync_visible_source_types", "type": "checkbox_set", @@ -3053,147 +3257,10 @@ "disabled": True, }, ], + "group": {"id": "source-types", "label": "Source types", "variant": "behavior"}, "depends_on": {"key": "enable_file_sync", "equals": True}, }, ], - "file-sync-personal-section": [ - { - "key": "enable_file_sync_personal", - "type": "switch", - "label": "Enable sync for personal workspaces", - "default": True, - "role": "capability", - }, - { - "key": "file_sync_personal_admin_only", - "type": "switch", - "label": "Only administrators manage sources", - "help": "Users keep their synced documents but cannot add or edit a source.", - "default": False, - "group": {"id": "access", "label": "Access", "variant": "access"}, - "depends_on": {"key": "enable_file_sync_personal", "equals": True}, - }, - { - "key": "file_sync_personal_require_app_role", - "type": "switch", - "label": "Require the PersonalFileSyncUser app role", - "help": ( - "Required app role value: PersonalFileSyncUser. Assign it in the " - "Enterprise App before turning this on, or no user will be able to " - "manage a personal source." - ), - "default": False, - "group": {"id": "access", "label": "Access", "variant": "access"}, - "depends_on": {"key": "enable_file_sync_personal", "equals": True}, - }, - ], - "file-sync-group-section": [ - { - "key": "enable_file_sync_group", - "type": "switch", - "label": "Enable sync for group workspaces", - "default": True, - "role": "capability", - }, - { - "key": "file_sync_group_admin_only", - "type": "switch", - "label": "Only administrators manage sources", - "default": False, - "group": {"id": "access", "label": "Access", "variant": "access"}, - "depends_on": {"key": "enable_file_sync_group", "equals": True}, - }, - { - "key": "require_group_assignment_for_file_sync", - "type": "switch", - "label": "Restrict to assigned groups", - "help": "Only the groups listed below may use File Sync.", - "default": False, - "group": {"id": "access", "label": "Access", "variant": "access"}, - "depends_on": {"key": "enable_file_sync_group", "equals": True}, - }, - { - "key": "file_sync_allowed_group_ids", - "type": "id_list", - "label": "Assigned groups", - "help": ( - "Leaving this empty while the restriction is on means no group can use " - "File Sync." - ), - "default": [], - "placeholder": "Search groups by name", - "search_endpoint": "/api/admin/file-sync/groups/search", - "search_param": "q", - "results_key": "groups", - "item_noun": "group", - "item_noun_plural": "groups", - # Group ids are canonical UUIDs, and the shared normalizer drops - # anything else, matching what the server-rendered form stores. - "id_kind": "group", - "group": {"id": "assignment", "label": "Assignment", "variant": "access"}, - "depends_on": { - "all_of": [ - {"key": "enable_file_sync_group", "equals": True}, - {"key": "require_group_assignment_for_file_sync", "equals": True}, - ] - }, - }, - ], - "file-sync-public-section": [ - { - "key": "enable_file_sync_public", - "type": "switch", - "label": "Enable sync for public workspaces", - "default": False, - "role": "capability", - }, - { - "key": "file_sync_public_admin_only", - "type": "switch", - "label": "Only administrators manage sources", - "default": False, - "group": {"id": "access", "label": "Access", "variant": "access"}, - "depends_on": {"key": "enable_file_sync_public", "equals": True}, - }, - { - "key": "require_public_workspace_assignment_for_file_sync", - "type": "switch", - "label": "Restrict to assigned public workspaces", - "help": "Only the public workspaces listed below may use File Sync.", - "default": False, - "group": {"id": "access", "label": "Access", "variant": "access"}, - "depends_on": {"key": "enable_file_sync_public", "equals": True}, - }, - { - "key": "file_sync_allowed_public_workspace_ids", - "type": "id_list", - "label": "Assigned public workspaces", - "help": ( - "Leaving this empty while the restriction is on means no public " - "workspace can use File Sync." - ), - "default": [], - "placeholder": "Search public workspaces by name", - "search_endpoint": "/api/admin/file-sync/public-workspaces/search", - "search_param": "q", - "results_key": "workspaces", - "item_noun": "public workspace", - "item_noun_plural": "public workspaces", - # Public workspace ids are not UUID-constrained, so they are only - # trimmed and deduplicated. - "id_kind": "opaque", - "group": {"id": "assignment", "label": "Assignment", "variant": "access"}, - "depends_on": { - "all_of": [ - {"key": "enable_file_sync_public", "equals": True}, - { - "key": "require_public_workspace_assignment_for_file_sync", - "equals": True, - }, - ] - }, - }, - ], "permissions-section": [ { "key": "require_member_of_safety_violation_admin", diff --git a/application/single_app/admin_settings_nav.py b/application/single_app/admin_settings_nav.py index f63b48806..39454ada4 100644 --- a/application/single_app/admin_settings_nav.py +++ b/application/single_app/admin_settings_nav.py @@ -309,10 +309,6 @@ "icon": "bi-arrow-repeat", "sections": [ {"id": "file-sync-section", "label": "File Sync", "icon": "bi-arrow-repeat"}, - {"id": "file-sync-source-types-section", "label": "Visible Source Types", "icon": "bi-sliders"}, - {"id": "file-sync-personal-section", "label": "Personal Workspace Sync", "icon": "bi-person"}, - {"id": "file-sync-group-section", "label": "Group Workspace Sync", "icon": "bi-people"}, - {"id": "file-sync-public-section", "label": "Public Workspace Sync", "icon": "bi-globe"}, ], }, ], diff --git a/application/single_app/config.py b/application/single_app/config.py index 670ab4808..3a890675e 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.259" +VERSION = "0.261.260" 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/admin/SettingsSection.tsx b/application/v2_ui/src/components/admin/SettingsSection.tsx index aee198871..f19da772a 100644 --- a/application/v2_ui/src/components/admin/SettingsSection.tsx +++ b/application/v2_ui/src/components/admin/SettingsSection.tsx @@ -20,7 +20,12 @@ // // Fields cluster into declared groups that collapse. A group opens when it is the one an // administrator needs next -- an empty connection on an enabled capability -- and stays -// shut otherwise, so a configured section is a summary rather than a wall. +// shut otherwise, so a configured section is a summary rather than a wall. A closed group +// says what is inside it: a count, or for a single choice list how much is chosen. +// +// A group can be anchored beneath a switch, so settings that belong to one toggle sit +// under that toggle rather than at the foot of the card. File Sync uses this to keep each +// workspace type's Access panel beside the type's own switch. // // A prerequisite owned by another section is stated where it is felt. Previously an // administrator could turn File Sync on and have nothing happen, because Redis Cache was @@ -41,7 +46,9 @@ import { collectRequirements, computeSectionStatus, deriveFieldHierarchy, + describeCollapsedGroup, findCapabilityField, + placeAnchoredGroups, readSectionValue, shouldGroupStartOpen, type FieldEmphasis, @@ -207,11 +214,20 @@ function FieldGroup({ startOpen, forceExpanded, renderFields, + summary, + contextLabel, }: { group: RenderedFieldGroup; startOpen: boolean; forceExpanded?: boolean; renderFields: (fields: AdminField[]) => ReactNode[]; + /** What the header says while closed: a count, or how much a choice list has chosen. */ + summary: string; + /** + * The switch an anchored panel sits beneath. Assistive technology reads it before the + * label, so several panels sharing a label such as "Access" stay distinguishable. + */ + contextLabel?: string; }) { const [open, setOpen] = useState(startOpen); @@ -228,7 +244,12 @@ function FieldGroup({ } return ( -
+
@@ -320,6 +339,32 @@ export function SettingsSection({ [bodyFields, settings, draft], ); + // A panel anchored to a switch is drawn beneath it, provided that switch is drawn at + // all. Anchors are ungrouped, so only the capability and the ungrouped run qualify. + const placement = useMemo(() => { + const rendered = new Set(); + if (capability?.key && isFieldVisible(capability, settings, draft)) { + rendered.add(capability.key); + } + for (const group of groups) { + if (group.id) { + continue; + } + for (const field of group.fields) { + if (field.key) { + rendered.add(field.key); + } + } + } + const labels = new Map(); + for (const field of hierarchyFields ?? fields) { + if (field.key) { + labels.set(field.key, field.label); + } + } + return placeAnchoredGroups(groups, rendered, (key) => labels.get(key)); + }, [capability, groups, hierarchyFields, fields, settings, draft]); + const presentation = presentSectionStatus(status); const SectionIcon = appearance?.Icon ?? icon ?? FALLBACK_SECTION_ICON; @@ -337,6 +382,7 @@ export function SettingsSection({ !field.readonly && Boolean(field.key) && !hierarchy.leads.has(field.key as string) && + !placement.anchored.has(field.key as string) && !emphasisOf(field); const decorateField = ( @@ -345,8 +391,9 @@ export function SettingsSection({ ) => { const emphasis = emphasisOf(field); const FieldIcon = appearance?.fields?.[field.key ?? '']?.Icon; + const panels = field.key ? placement.anchored.get(field.key) : undefined; const control = renderer(field); - if ((!emphasis && !FieldIcon) || control == null) { + if (control == null || (!emphasis && !FieldIcon && !panels)) { return control; } return ( @@ -369,7 +416,14 @@ export function SettingsSection({ ) : null} -
{control}
+
+ {control} + {panels ? ( +
+ {panels.map((group) => renderGroup(group, field.label))} +
+ ) : null} +
); }; @@ -381,6 +435,18 @@ export function SettingsSection({ flowsInGrid, ); + const renderGroup = (group: RenderedFieldGroup, contextLabel?: string) => ( + + ); + return ( ) : null} - {groups.map((group) => ( - - ))} + {placement.topLevel.map((group) => renderGroup(group))} {children}
diff --git a/application/v2_ui/src/lib/adminFields.ts b/application/v2_ui/src/lib/adminFields.ts index 308b9bc5b..07d4833fe 100644 --- a/application/v2_ui/src/lib/adminFields.ts +++ b/application/v2_ui/src/lib/adminFields.ts @@ -97,6 +97,15 @@ export interface AdminFieldGroup { label?: string; variant?: 'connection' | 'behavior' | 'limits' | 'access' | 'advanced'; help?: string; + /** + * Settings key of the ungrouped switch this group sits beneath. + * + * An anchored group is drawn as a panel directly under that switch rather than at the + * foot of the card, which is how each File Sync workspace type keeps its Access panel + * beside its own toggle. Every field in it depends on the anchor, so the panel never + * shows while the switch is off. + */ + anchor?: string; } /** Normalise either declared group shape into the object form. */ @@ -472,6 +481,8 @@ export interface RenderedFieldGroup { * always-on built-in actions out of the way. */ collapsed?: boolean; + /** The switch the group is drawn beneath. See `AdminFieldGroup.anchor`. */ + anchor?: string; fields: AdminField[]; } @@ -499,6 +510,7 @@ export function groupFields(fields: AdminField[]): RenderedFieldGroup[] { variant: declared?.variant, help: declared?.help, collapsed: Boolean(field.collapsed), + anchor: declared?.anchor, fields: [], }; byId.set(id, group); diff --git a/application/v2_ui/src/lib/adminSections.ts b/application/v2_ui/src/lib/adminSections.ts index b79baa42e..72bbebcc5 100644 --- a/application/v2_ui/src/lib/adminSections.ts +++ b/application/v2_ui/src/lib/adminSections.ts @@ -9,9 +9,11 @@ import { asBoolean, asString, + asStringArray, evaluateDependency, evaluateSectionStatus, readFieldGroup, + readFieldValue, type AdminField, type AdminFieldDependency, type AdminFieldRequirement, @@ -317,3 +319,74 @@ export function shouldGroupStartOpen( } return group.variant === 'connection' && status === 'incomplete'; } + +/** Where a section's groups are drawn. */ +export interface GroupPlacement { + /** Groups drawn in the card body, in declared order. */ + topLevel: RenderedFieldGroup[]; + /** Groups drawn beneath a switch, keyed by that switch's settings key. */ + anchored: ReadonlyMap; +} + +/** + * Decide which groups sit beneath a switch and which in the card body. + * + * A group names its switch with `anchor`, and is placed there only while that switch is + * actually drawn. A search that matches a panel's settings but not its switch would + * otherwise leave the panel nowhere to appear, so it falls back to the card body -- and + * takes the switch's name into its label, because three panels all called "Access" are + * indistinguishable without the toggle above them. + */ +export function placeAnchoredGroups( + groups: RenderedFieldGroup[], + renderedKeys: ReadonlySet, + labelOf: (key: string) => string | undefined, +): GroupPlacement { + const topLevel: RenderedFieldGroup[] = []; + const anchored = new Map(); + + for (const group of groups) { + const anchor = group.anchor; + if (!anchor) { + topLevel.push(group); + continue; + } + if (renderedKeys.has(anchor)) { + anchored.set(anchor, [...(anchored.get(anchor) ?? []), group]); + continue; + } + const anchorLabel = labelOf(anchor); + topLevel.push( + anchorLabel + ? { ...group, label: `${anchorLabel} · ${group.label ?? group.id}` } + : group, + ); + } + + return { topLevel, anchored }; +} + +/** + * What a collapsed group's header says about its contents. + * + * A count is all most groups can offer. A group holding a single choice list can say how + * much is chosen instead, which is what an administrator scanning a configured card + * wants: "3 selected" under Source types answers a question "1 setting" does not. Only + * declared options are counted, the same ones the control draws as checked. + */ +export function describeCollapsedGroup( + group: RenderedFieldGroup, + settings: Json, + draft: Json, +): string { + const [only] = group.fields; + if (group.fields.length === 1 && only?.type === 'checkbox_set') { + const selected = asStringArray(readFieldValue(only, settings, draft)); + const count = (only.options ?? []).filter((option) => + selected.includes(option.value), + ).length; + return `${count} selected`; + } + const total = group.fields.length; + return `${total} ${total === 1 ? 'setting' : 'settings'}`; +} diff --git a/application/v2_ui/src/styles/theme.css b/application/v2_ui/src/styles/theme.css index 047fbb51d..8f83c9b59 100644 --- a/application/v2_ui/src/styles/theme.css +++ b/application/v2_ui/src/styles/theme.css @@ -429,6 +429,13 @@ html[data-font-size='xl'] { grid-template-columns: minmax(11rem, calc(26rem - 1.5rem - 2px)) minmax(18rem, 1fr); } + /* An anchored panel lines up with the text of the switch it belongs to, the + same 3.5rem a switch row indents its notices by. A narrow card keeps the full + width instead, where the indent would cost more room than it explains. */ + .admin-anchored-groups { + margin-inline-start: 3.5rem; + } + .admin-field[data-field-width='compact'] > .admin-field-control { max-width: 12rem; } diff --git a/docs/_data/app_surface.yml b/docs/_data/app_surface.yml index 5e81978c6..9ef9caede 100644 --- a/docs/_data/app_surface.yml +++ b/docs/_data/app_surface.yml @@ -4,7 +4,7 @@ counts: capabilities: 126 admin_groups: 15 admin_tabs: 49 - admin_sections: 110 + admin_sections: 106 actions: 32 chat_controls: 47 sub_elements: 3 @@ -743,26 +743,10 @@ admin_sections: label: Maximum File Size tab: extraction group: knowledge -- id: file-sync-group-section - label: Group Workspace Sync - tab: file-sync - group: knowledge -- id: file-sync-personal-section - label: Personal Workspace Sync - tab: file-sync - group: knowledge -- id: file-sync-public-section - label: Public Workspace Sync - tab: file-sync - group: knowledge - id: file-sync-section label: File Sync tab: file-sync group: knowledge -- id: file-sync-source-types-section - label: Visible Source Types - tab: file-sync - group: knowledge - id: front-door-section label: Azure Front Door tab: network diff --git a/docs/admin/knowledge.md b/docs/admin/knowledge.md index a261c74b3..95bf12eed 100644 --- a/docs/admin/knowledge.md +++ b/docs/admin/knowledge.md @@ -365,55 +365,89 @@ resource, so it lives with the other notification settings under ### File Sync {#file-sync-section} -The File Sync section belongs to the File Sync tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned. - -### Visible Source Types {#file-sync-source-types-section} - -The Visible Source Types section belongs to the File Sync tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned. - -### Personal Workspace Sync {#file-sync-personal-section} - -The Personal Workspace Sync section belongs to the File Sync tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned. - -### Group Workspace Sync {#file-sync-group-section} - -The Group Workspace Sync section belongs to the File Sync tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned. - -### Public Workspace Sync {#file-sync-public-section} - -The Public Workspace Sync section belongs to the File Sync tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned. +File Sync lets a workspace pull documents from an external source on a schedule instead +of relying on manual upload. In V2 Admin Settings everything that governs it is one +card, read from the top down: whether File Sync is on, which workspace types may use it +and who manages their sources, how much a single run may do, and which kinds of source +can be added. + +Sync runs need [Redis Cache](scale.md#redis-cache-section) switched on with its URL set, +and its key when Redis uses key authentication. You can turn File Sync on and save its +settings first, but runs stay inactive until Redis is ready. The V2 card warns while +Redis Cache is switched off but does not check the URL or key, so confirm those under +Redis Cache. + +#### Workspace types + +Turning File Sync on shows **Personal workspaces**, **Group workspaces**, and **Public +workspaces** nested beneath it. A workspace type can sync only while both File Sync and +its own switch are on, which is why the type switches are hidden while File Sync is +off. Personal and group workspaces are on by default; public workspaces are off. + +Each workspace type that is on has an **Access** panel directly under its switch. Its +rules decide who can open and manage that type's sources. Apart from the assignment +lists, they do not stop sources that already exist: those keep syncing on their +schedule. + +- **Only administrators manage sources** stops anyone without the Admin role from + opening or changing that workspace type's sources. Documents that have already synced + stay. +- **Require the PersonalFileSyncUser app role** (personal workspaces only) limits + opening and managing personal sources to holders of the `PersonalFileSyncUser` app + role. Create and assign the role in the Enterprise App first, or no user will be able + to manage their own personal sources. The requirement is also listed under + [App Role Requirements](security.md#app-role-requirements-section). +- **Restrict to assigned groups** and **Restrict to assigned public workspaces** limit + File Sync to the workspaces chosen in the list that appears beneath the switch. + Workspaces left off the list can no longer manage their sources, and their scheduled + runs are skipped, so an empty list with the restriction on leaves no workspace of + that type able to manage or schedule a sync. + +None of these rules apply to administrators managing sources on a workspace's behalf. +The classic admin page has a tool for that on its File Sync card: search for a user, +group, or public workspace and manage its sources directly. V2 does not offer that tool +yet. + +#### Run limits + +Run limits bound what a single workspace and a single run can do, and they apply to +every workspace type. A run skips the files that would take it past its file or size +limit rather than failing, and no new run starts while the concurrent run limit is +reached. Start with low limits and a conservative schedule: a broad share can create +many document versions in one run. + +#### Source types + +Source types decides which kinds of source the Add Source workflow offers. SMB Share, +Azure Files, and Azure Blob Storage are available; OneDrive, on-premises SharePoint, +and Google Workspace are listed as coming soon and cannot be selected yet. At least one +source type must stay selected while File Sync is on. While the panel is closed, its +header shows how many are selected. + +With Key Vault secret storage enabled and a Key Vault name set, a source's credentials +are stored in Key Vault; otherwise they are stored with the source itself. #### Settings | Setting | What it does | Default | Notes | | --- | --- | --- | --- | -| Enable File Sync | Enables the File Sync feature so configured external sources can import files into workspaces. | Off | `enable_file_sync`; capability toggle | -| Max Sources | Defines a capacity or timing boundary that keeps the feature inside supported limits. | 10 | `file_sync_max_sources_per_scope` | -| Min Schedule Minutes | Defines a capacity or timing boundary that keeps the feature inside supported limits. | 15 | `file_sync_min_schedule_interval_minutes` | -| Max Files Per Run | Defines a capacity or timing boundary that keeps the feature inside supported limits. | 1000 | `file_sync_max_files_per_run` | -| Max GB Per Run | Defines a capacity or timing boundary that keeps the feature inside supported limits. | 5 GB | `file_sync_max_gb_per_run` | -| Max Concurrent Runs | Defines a capacity or timing boundary that keeps the feature inside supported limits. | 2 | `file_sync_max_concurrent_runs` | -| Allow Recursive Sources | Defines behavior for the related admin workflow; verify the affected feature after saving. | On | `file_sync_allow_recursive_sources` | -| SMB Share | Available now. | On | `file_sync_visible_source_types` | -| OneDrive | Coming Soon. | Off | `file_sync_visible_source_type_onedrive` | -| On-prem SharePoint | Coming Soon. | Off | `file_sync_visible_source_type_sharepoint_on_prem` | -| Google Workspace | Coming Soon. | Off | `file_sync_visible_source_type_google_workspace` | -| Enable personal sync | Exposes the capability after required services, permissions, and rollout policy are ready. | On | `enable_file_sync_personal`; capability toggle | -| Admins manage sources only | Defines behavior for the related admin workflow; verify the affected feature after saving. | Off | `file_sync_personal_admin_only` | -| Require PersonalFileSyncUser App Role | Defines behavior for the related admin workflow; verify the affected feature after saving. | Off | `file_sync_personal_require_app_role` | -| Manage User Sources | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | Runtime UI control | -| Enable group sync | Exposes the capability after required services, permissions, and rollout policy are ready. | On | `enable_file_sync_group`; capability toggle | -| Admins manage sources only | Defines behavior for the related admin workflow; verify the affected feature after saving. | Off | `file_sync_group_admin_only` | -| Require Group Assignment to Use File Sync | Defines behavior for the related admin workflow; verify the affected feature after saving. | Off | `require_group_assignment_for_file_sync` | -| File Sync Allowed Group Ids | Lists the approved IDs, domains, groups, workspaces, or sources that may use this feature. | Empty list | `file_sync_allowed_group_ids` | -| Manage Group Sources | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | Runtime UI control | -| Enable public sync | Exposes the capability after required services, permissions, and rollout policy are ready. | Off | `enable_file_sync_public`; capability toggle | -| Admins manage sources only | Defines behavior for the related admin workflow; verify the affected feature after saving. | Off | `file_sync_public_admin_only` | -| Require Public Workspace Assignment to Use File Sync | Defines behavior for the related admin workflow; verify the affected feature after saving. | Off | `require_public_workspace_assignment_for_file_sync` | -| File Sync Allowed Public Workspace Ids | Lists the approved IDs, domains, groups, workspaces, or sources that may use this feature. | Empty list | `file_sync_allowed_public_workspace_ids` | -| Manage Public Workspace Sources | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | Runtime UI control | -| Search Groups | Defines behavior for the related admin workflow; verify the affected feature after saving. | N/A (runtime control) | Runtime UI control | -| Search Public Workspaces | Defines behavior for the related admin workflow; verify the affected feature after saving. | N/A (runtime control) | Runtime UI control | +| Enable File Sync | Lets workspaces pull documents from a configured source on a schedule instead of relying on manual upload. | Off | `enable_file_sync`; capability toggle; runs need a configured Redis Cache | +| Personal workspaces | Allows File Sync in personal workspaces while File Sync is on. | On | `enable_file_sync_personal` | +| Group workspaces | Allows File Sync in group workspaces while File Sync is on. | On | `enable_file_sync_group` | +| Public workspaces | Allows File Sync in public workspaces while File Sync is on. | Off | `enable_file_sync_public` | +| Only administrators manage sources | Stops anyone without the Admin role from opening or changing that workspace type's sources. Existing sources keep syncing on their schedule. | Off | `file_sync_personal_admin_only`, `file_sync_group_admin_only`, `file_sync_public_admin_only`; one in each Access panel | +| Require the PersonalFileSyncUser app role | Limits opening and managing personal sources to holders of the `PersonalFileSyncUser` app role. Existing sources keep syncing on their schedule. | Off | `file_sync_personal_require_app_role` | +| Restrict to assigned groups | Limits group File Sync to the groups in Assigned groups; scheduled runs for other groups are skipped. | Off | `require_group_assignment_for_file_sync` | +| Assigned groups | The groups that may use File Sync while the restriction is on. An empty list allows none. | Empty list | `file_sync_allowed_group_ids` | +| Restrict to assigned public workspaces | Limits public File Sync to the workspaces in Assigned public workspaces; scheduled runs for other public workspaces are skipped. | Off | `require_public_workspace_assignment_for_file_sync` | +| Assigned public workspaces | The public workspaces that may use File Sync while the restriction is on. An empty list allows none. | Empty list | `file_sync_allowed_public_workspace_ids` | +| Max Sources per Workspace | The most sync sources one workspace can hold; adding another is refused. | 10 | `file_sync_max_sources_per_scope`; 1–100 | +| Minimum Schedule Interval | The shortest gap a workspace may schedule between runs. | 15 minutes | `file_sync_min_schedule_interval_minutes`; 5–1440 | +| Max Files per Run | The most files one run imports; files past the limit are skipped in that run. | 1000 | `file_sync_max_files_per_run`; 1–100000 | +| Max Size per Run | The most data one run imports; a file that would exceed it is skipped in that run. Entered in gigabytes, stored in bytes. | 5 GB | `file_sync_max_bytes_per_run`; 1–1024 GB | +| Max Concurrent Runs | How many sync runs may be queued or running at once across the whole deployment; no new run starts while the limit is reached. | 2 | `file_sync_max_concurrent_runs`; 1–25 | +| Allow recursive sources | Lets a source include subfolders rather than only its top level. | On | `file_sync_allow_recursive_sources` | +| Source types offered when adding a source | Which source types the Add Source workflow offers. | SMB Share, Azure Files | `file_sync_visible_source_types`; at least one while File Sync is on | ## Common tasks diff --git a/docs/explanation/features/V2_ADMIN_FILE_SYNC_SINGLE_CARD.md b/docs/explanation/features/V2_ADMIN_FILE_SYNC_SINGLE_CARD.md new file mode 100644 index 000000000..d4a1617be --- /dev/null +++ b/docs/explanation/features/V2_ADMIN_FILE_SYNC_SINGLE_CARD.md @@ -0,0 +1,158 @@ +# V2 Admin File Sync Single Card + +## Overview + +In V2 Admin Settings, File Sync used to be five unrelated cards: File Sync with its run +limits, Visible Source Types, and one card each for Personal, Group, and Public Workspace +Sync, each with its own switch and Access panel. Nothing tied a workspace type or the +source types back to the File Sync switch they depend on, so an administrator had to +read five cards to understand one capability. + +File Sync is now one card, read from the top down: + +- **Enable File Sync** is the card's switch, with the Redis Cache warning it already had. +- Turning it on shows **Personal workspaces**, **Group workspaces**, and **Public + workspaces** nested beneath it. A workspace type can sync only while both File Sync + and its own switch are on (`functions_file_sync.py`), and the nesting now says so. +- Each workspace type that is on has an **Access** panel directly under its switch, + holding that type's rules: administrators-only source management, the + PersonalFileSyncUser app role, and the assigned groups or public workspaces. +- **Run limits** and **Source types** (formerly Visible Source Types) are collapsible + panels shared by every workspace type. Closed, Source types reports how many types + are selected rather than "1 setting". + +## Implemented in version: **0.261.260** + +The application version is maintained in `application/single_app/config.py`. + +**Dependencies:** the React/TypeScript V2 UI, `admin_settings_nav.py`, +`admin_settings_fields.py`, and `admin_app_roles.py`. No new packages, settings keys, +routes, or browser asset sources are required. + +## Technical specifications + +### One section in the schema + +`ADMIN_NAV` lists a single section for the File Sync tab, `file-sync-section`. The four +sections it replaces (`file-sync-source-types-section`, `file-sync-personal-section`, +`file-sync-group-section`, `file-sync-public-section`) are gone from both the navigation +and `ADMIN_SETTINGS_FIELDS`, so V2 draws one card. + +The fields are declared in an order the renderer depends on: + +1. `enable_file_sync`, the capability. +2. The three workspace type switches, contiguous and ungrouped, each with + `depends_on: enable_file_sync`. `deriveFieldHierarchy` nests an uninterrupted run + under a lead switch, so anything declared between them would leave the switches + after it un-nested. They no longer declare `role: capability`; a card has one. +3. The three Access groups, one per workspace type. +4. Run limits, unchanged. +5. Source types, now in a `source-types` group labelled "Source types". + +Field visibility is not transitive in either renderer, so every field in an Access +panel repeats the File Sync condition as a chain, for example +`[enable_file_sync, enable_file_sync_group]`, and an assignment list adds its +restriction switch. Without the File Sync condition a panel would stay visible after +File Sync is switched off. + +No settings keys, defaults, normalizers, legacy field names, or stored shapes changed. + +### Anchored groups + +A group descriptor may name an `anchor`: the key of an ungrouped switch declared earlier +in the same section. + +```python +"group": { + "id": "group-access", + "label": "Access", + "variant": "access", + "anchor": "enable_file_sync_group", +}, +``` + +`groupFields` carries the anchor through. `placeAnchoredGroups` in `adminSections.ts` +splits a section's groups into those drawn in the card body and those drawn beneath a +switch. A group is anchored only while its switch is actually drawn. When a search +matches a panel's settings but not its switch, the panel falls back to the card body +and its label takes the switch's name ("Group workspaces · Access"), because three +panels called "Access" would otherwise be indistinguishable. + +`SettingsSection` renders an anchored panel inside its switch's row, after the control, +so it shares the row's nested rail. An anchor never flows into the two-column switch +grid. The panel button carries the switch's name for assistive technology through a +visually hidden prefix ("Group workspaces: Access"). On a card 50rem and wider, +`.admin-anchored-groups` in `theme.css` indents the panel 3.5rem to line up with the +switch's label, the same offset a switch row uses for its notices; on a narrower card +the panel keeps the full row. + +Sections that declare no anchor render exactly as before. + +### Collapsed group summaries + +`describeCollapsedGroup` in `adminSections.ts` decides what a closed group's header +says. A group holding a single `checkbox_set` reports how many of its declared options +are checked, read from the unsaved draft first, for example "3 selected". Every other +group keeps its count, "6 settings". Only the File Sync Source types panel has that +shape today. + +### Related registry change + +`admin_app_roles.py` points the `PersonalFileSyncUser` requirement's `section_id` at +`file-sync-section`, so "Go to setting" in the Security roster lands on the card. + +### Classic admin page + +The server-rendered pane is unchanged: its markup, card ids, and "Visible Source Types" +heading stay as they were, and its deep links still resolve. Because the navigation is +shared, its sidebar now lists a single File Sync destination; that tab was already one +card, so the four sub-links only scrolled within it. + +### File structure + +| File | Change | +| --- | --- | +| `application/single_app/admin_settings_fields.py` | One `file-sync-section`, nested workspace types, anchored Access groups, Source types group; `anchor` documented. | +| `application/single_app/admin_settings_nav.py` | File Sync tab lists one section. | +| `application/single_app/admin_app_roles.py` | PersonalFileSyncUser links to `file-sync-section`. | +| `application/v2_ui/src/lib/adminFields.ts` | `anchor` on `AdminFieldGroup` and `RenderedFieldGroup`. | +| `application/v2_ui/src/lib/adminSections.ts` | `placeAnchoredGroups`, `describeCollapsedGroup`. | +| `application/v2_ui/src/components/admin/SettingsSection.tsx` | Anchored panels, accessible panel names, collapsed summaries. | +| `application/v2_ui/src/styles/theme.css` | Label-aligned indent for anchored panels on wide cards. | +| `docs/_data/app_surface.yml` | Regenerated; four sections removed. | +| `docs/admin/knowledge.md` | File Sync section rewritten for the single card. | + +## Usage + +Open **Admin Settings** in V2 and choose **Knowledge**, or search for "File Sync". +Turn on **Enable File Sync**, then turn on the workspace types that should sync. Open a +type's **Access** panel to restrict who manages its sources or which workspaces may +use it. Adjust **Run limits** and **Source types** for every workspace type at once. +See [Knowledge settings](../../admin/knowledge.md#file-sync-section). + +## Testing and validation + +- `functional_tests/test_v2_admin_knowledge_file_sync.py` pins the single section, the + contiguous nested workspace types, one anchored Access panel per type, the File Sync + condition on every anchored field, assignment lists nested under their restriction, + the Run limits and Source types panels, the Redis prerequisite, the GB-to-bytes + conversion, and V1 field parity. +- `functional_tests/test_v2_admin_schema_vocabulary.py` rejects an anchor that names no + earlier ungrouped switch, an anchored field that does not require its switch and the + switch's own conditions, and a group whose fields disagree on the anchor. +- `functional_tests/test_v2_admin_section_logic.ts`, run by + `test_v2_admin_section_shell.py`, executes placement, the search fallback label, the + collapsed summaries, and a rendered File Sync card in which each Access panel sits + between its own switch and the next. +- `ui_tests/test_v2_admin_file_sync_single_card.py` drives the built SPA with the real + schema: one card, nested types, folding when File Sync is switched off, panel + placement and keyboard disclosure at 1440px and 390px, the live "N selected" + summary, and no overflow at 390px and 1920px with normal and large text in light and + dark. + +### Known limitations + +- An anchor must be an ungrouped switch. A panel cannot hang beneath a switch that is + itself inside a collapsible group. +- The summary is specific to a lone choice list; other groups still report a count. +- The classic page's administrator-managed source search has no V2 equivalent yet. diff --git a/docs/explanation/features/V2_ADMIN_KNOWLEDGE_SETTINGS.md b/docs/explanation/features/V2_ADMIN_KNOWLEDGE_SETTINGS.md index 5b41ea28b..4565805c6 100644 --- a/docs/explanation/features/V2_ADMIN_KNOWLEDGE_SETTINGS.md +++ b/docs/explanation/features/V2_ADMIN_KNOWLEDGE_SETTINGS.md @@ -51,7 +51,7 @@ configuration. | `status` field type | A server-computed readout that is displayed but never stored. | | `depends_on` composition | `equals`, `not_equals`, `any_of` and `all_of`, nestable. Needed because one block can be revealed by several independent capabilities. | | `requires` | A prerequisite owned by a different section, mirroring the `data-requires` attributes `admin_settings_dependencies.js` reads. | -| `group` | An ordered, labelled cluster of fields with a variant, which is what the renderer discloses progressively. | +| `group` | An ordered, labelled cluster of fields with a variant, which is what the renderer discloses progressively. A group may name an `anchor` switch to be drawn beneath it; see [V2 Admin File Sync Single Card](V2_ADMIN_FILE_SYNC_SINGLE_CARD.md). | | `role: capability` | Marks the switch a section hangs off, so the renderer can lift it into the section header. | | `paths` | Where a value is stored when that is not a top-level key of its own name. | | `scale` | The multiplier between the unit a field is edited in and the unit it is stored in. | @@ -138,19 +138,22 @@ that the two interfaces order or group them identically. The intentional differe `get_source_review_config` rewrites any other value on read. - The Video Indexer cloud selector not reproduced. It has no stored setting of its own, existing only to compute `video_indexer_endpoint`, which V2 edits directly. +- File Sync drawn as one card (since 0.261.260): the workspace types nest under Enable + File Sync, each with its Access rules in a panel beneath it, and Visible Source Types + is a "Source types" panel. The V1 pane keeps its own layout. ## Testing | Test | Covers | | --- | --- | -| `test_v2_admin_schema_vocabulary.py` | Field types, dependency evaluation, list coercion, secret handling | +| `test_v2_admin_schema_vocabulary.py` | Field types, dependency evaluation, list coercion, secret handling, anchored groups | | `test_v2_admin_settings_secret_handling.py` | Redaction on read and write; untouched credentials never written | | `test_v2_admin_section_shell.py` | Shared dispatcher, route guards, page wiring, plus the TypeScript checks | -| `test_v2_admin_section_logic.ts` | Status derivation and group disclosure rules | +| `test_v2_admin_section_logic.ts` | Status derivation, group disclosure rules, anchored panels, collapsed summaries | | `test_v2_admin_knowledge_web_research.py` | Parity, nested storage, consent gate, auth branching | | `test_v2_admin_knowledge_extraction.py` | Parity, connection-first ordering, chunk size storage | | `test_v2_admin_knowledge_audio_video.py` | Parity, audio cue relocation, shared Speech disclosure | -| `test_v2_admin_knowledge_file_sync.py` | Parity, Redis prerequisite, GB/bytes conversion, assignments | +| `test_v2_admin_knowledge_file_sync.py` | Parity, single card, nested workspace types, anchored Access panels, Redis prerequisite, GB/bytes conversion, assignments | | `test_model_vision_capability_resolution.py` | Three-tier resolution and its precedence | ## Known limitations diff --git a/docs/explanation/features/V2_ADMIN_SETTINGS_LAYOUT_AND_HIERARCHY.md b/docs/explanation/features/V2_ADMIN_SETTINGS_LAYOUT_AND_HIERARCHY.md index 1d016ceb9..8691c6241 100644 --- a/docs/explanation/features/V2_ADMIN_SETTINGS_LAYOUT_AND_HIERARCHY.md +++ b/docs/explanation/features/V2_ADMIN_SETTINGS_LAYOUT_AND_HIERARCHY.md @@ -69,6 +69,15 @@ example the classification banner, the AI notice, and the APIM routing choice in Azure AI Search. A lead inside a group, such as that APIM switch, nests its settings without being promoted to the section's primary switch. +### Anchored panels and collapsed summaries + +Since 0.261.260 a group can name an `anchor` switch, and `placeAnchoredGroups` draws it +as a panel inside that switch's row instead of at the foot of the card. File Sync uses +this so each workspace type's Access rules sit beneath its own switch. A closed group +holding a single choice list says how many options are selected ("3 selected") rather +than "1 setting", through `describeCollapsedGroup`. See +[V2 Admin File Sync Single Card](V2_ADMIN_FILE_SYNC_SINGLE_CARD.md). + ### Field layout `FieldShell` in `fields.tsx` renders a heading, help, and control region. The rules @@ -107,7 +116,7 @@ and focuses its heading. | `application/v2_ui/src/components/admin/adminSectionIcons.ts` | Navigation icon names to Lucide icons. | | `application/v2_ui/src/components/admin/sectionStatusPresentation.ts` | Status labels, tones, and icons. | | `application/v2_ui/src/components/admin/fields.tsx` | Split field shell and control widths. | -| `application/v2_ui/src/lib/adminSections.ts` | `deriveFieldHierarchy`, `computeSectionStatus`. | +| `application/v2_ui/src/lib/adminSections.ts` | `deriveFieldHierarchy`, `computeSectionStatus`, `placeAnchoredGroups`, `describeCollapsedGroup`. | | `application/v2_ui/src/styles/theme.css` | Field, switch-grid, and alignment rules. | | `application/v2_ui/src/components/ui/primitives.tsx` | Optional label and description classes on `Toggle`. | diff --git a/functional_tests/test_v2_admin_knowledge_file_sync.py b/functional_tests/test_v2_admin_knowledge_file_sync.py index 1ad0418ba..b16e50c31 100644 --- a/functional_tests/test_v2_admin_knowledge_file_sync.py +++ b/functional_tests/test_v2_admin_knowledge_file_sync.py @@ -2,8 +2,9 @@ #!/usr/bin/env python3 """ Functional test for the Knowledge group's File Sync tab in the V2 admin UI. -Version: 0.261.122 +Version: 0.261.260 Implemented in: 0.261.084 +Consolidated into one card in: 0.261.260 File Sync is the first section to declare a prerequisite owned by another group. It needs Redis Cache, which lives under Scale, and the server-rendered card says @@ -22,6 +23,15 @@ but never wired up the JavaScript for them, so ``file_sync_allowed_group_ids`` and ``file_sync_allowed_public_workspace_ids`` have not been editable from either interface. + +Everything File Sync governs is one card. It used to be five -- the switch with +its run limits, the source types, and a card per workspace type -- and nothing +tied a workspace type back to the switch it depends on. A workspace type is only +live while File Sync and its own switch are both on, so the type switches now +nest under File Sync, and each type's access rules sit in a panel anchored +beneath that type's switch. The structure is what these tests hold still: the +nesting is derived from declaration order and ``depends_on``, so a reordered or +under-gated field silently breaks it. """ import re @@ -46,23 +56,30 @@ / "file-sync.html" ) -FILE_SYNC_SECTIONS = ( - "file-sync-section", - "file-sync-source-types-section", - "file-sync-personal-section", - "file-sync-group-section", - "file-sync-public-section", +FILE_SYNC_SECTION = "file-sync-section" + +FILE_SYNC_SECTIONS = (FILE_SYNC_SECTION,) + +# The workspace type switches, in the order they nest under File Sync. +SCOPE_SWITCHES = ( + "enable_file_sync_personal", + "enable_file_sync_group", + "enable_file_sync_public", ) -SCOPE_SECTIONS = ( - "file-sync-personal-section", - "file-sync-group-section", - "file-sync-public-section", +RUN_LIMIT_KEYS = ( + "file_sync_max_sources_per_scope", + "file_sync_min_schedule_interval_minutes", + "file_sync_max_files_per_run", + "file_sync_max_gb_per_run", + "file_sync_max_concurrent_runs", + "file_sync_allow_recursive_sources", ) GIBIBYTE = 1073741824 fields_module = import_app_module("admin_settings_fields") +roles_module = import_app_module("admin_app_roles") normalize = fields_module.normalize_admin_settings_updates evaluate = fields_module.evaluate_dependency @@ -89,11 +106,33 @@ def field_in(section_id, key): ) -def test_the_tab_sections_match_navigation(): - """A field filed under an unknown section id would never render.""" - print("Testing File Sync sections against ADMIN_NAV...") +def group_of(field): + """The field's group descriptor as a dict, whichever shape it was declared in.""" + group = field.get("group") + if isinstance(group, str): + return {"id": group, "label": group} + return group or {} + - assert_app_version_at_least("0.261.084") +def anchored_to(anchor): + """The File Sync fields drawn in the panel beneath ``anchor``.""" + return [ + field + for field in section_fields(FILE_SYNC_SECTION) + if group_of(field).get("anchor") == anchor + ] + + +def reader(**values): + """A settings reader for ``evaluate_dependency``; unset keys read as None.""" + return values.get + + +def test_the_tab_is_one_card(): + """Five cards for one capability is what this layout replaces.""" + print("Testing the File Sync tab against ADMIN_NAV...") + + assert_app_version_at_least("0.261.260") nav_sections = tuple( section["id"] @@ -105,12 +144,24 @@ def test_the_tab_sections_match_navigation(): ) assert nav_sections == FILE_SYNC_SECTIONS, ( - f"ADMIN_NAV: {list(nav_sections)}\n test: {list(FILE_SYNC_SECTIONS)}" + "The File Sync tab should be a single card, so the workspace types and " + "source types read as part of File Sync rather than beside it.\n" + f" ADMIN_NAV: {list(nav_sections)}\n test: {list(FILE_SYNC_SECTIONS)}" ) - for section_id in FILE_SYNC_SECTIONS: - assert section_fields(section_id), f"{section_id} declares no fields." + assert section_fields(FILE_SYNC_SECTION), f"{FILE_SYNC_SECTION} declares no fields." - print(f" All {len(nav_sections)} section(s) exist in ADMIN_NAV and are described.") + # A field filed under a section the navigation no longer names is never drawn. + stray = sorted( + section_id + for section_id in fields_module.get_admin_settings_fields() + if section_id.startswith("file-sync") and section_id not in FILE_SYNC_SECTIONS + ) + assert not stray, ( + f"These File Sync sections are declared but not in ADMIN_NAV, so their " + f"fields would never render: {stray}" + ) + + print(f" The tab is one card with {len(section_fields(FILE_SYNC_SECTION))} fields.") return True @@ -186,31 +237,104 @@ def test_the_per_run_size_limit_converts_between_gb_and_bytes(): return True -def test_the_scope_sections_share_one_shape(): - """Learning one scope should be enough to read the other two.""" - print("\nTesting the scope sections...") +def test_workspace_types_nest_under_file_sync(): + """A workspace type only works while File Sync is on, and should read that way.""" + print("\nTesting the workspace type switches...") + + fields = section_fields(FILE_SYNC_SECTION) + keys = [field.get("key") for field in fields] + + capabilities = [field for field in fields if field.get("role") == "capability"] + assert [field.get("key") for field in capabilities] == ["enable_file_sync"], ( + "Enable File Sync should be the card's only capability switch; a second one " + f"would compete with it for the header: {[f.get('key') for f in capabilities]}" + ) + assert keys[0] == "enable_file_sync", f"The card should open with its switch: {keys[:3]}" - for section_id in SCOPE_SECTIONS: - fields = section_fields(section_id) + # deriveFieldHierarchy nests an uninterrupted run under a switch, so anything + # declared between these would leave the switches after it un-nested. + assert tuple(keys[1:4]) == SCOPE_SWITCHES, ( + "The workspace type switches must follow Enable File Sync directly and in " + f"order, or they stop drawing as nested beneath it: {keys[1:4]}" + ) - capability = [field for field in fields if field.get("role") == "capability"] - assert len(capability) == 1, ( - f"{section_id} names {len(capability)} capability switches; each scope " - "should name exactly one." + for key in SCOPE_SWITCHES: + field = field_in(FILE_SYNC_SECTION, key) + assert field["type"] == "switch", f"{key} should be a switch: {field['type']}" + assert not field.get("group"), f"{key} should be ungrouped so it can nest." + assert field.get("depends_on") == {"key": "enable_file_sync", "equals": True}, ( + f"{key} should appear only while File Sync is on, because " + "functions_file_sync.py ignores it otherwise: " + f"{field.get('depends_on')}" ) + assert evaluate(field["depends_on"], reader(enable_file_sync=True)) + assert not evaluate(field["depends_on"], reader(enable_file_sync=False)) - admin_only = [ - field for field in fields if field.get("key", "").endswith("_admin_only") - ] - assert len(admin_only) == 1, ( - f"{section_id} should offer an administrators-only control like the " - "other scopes." + print(f" {len(SCOPE_SWITCHES)} workspace type switch(es) nest under File Sync.") + return True + + +def test_each_workspace_type_has_an_anchored_access_panel(): + """Access rules belong beside the switch they qualify, not at the foot of the card.""" + print("\nTesting the per-type Access panels...") + + group_ids = set() + for scope in SCOPE_SWITCHES: + panel = anchored_to(scope) + assert panel, f"{scope} has no Access panel anchored beneath it." + + declared = { + (group_of(field).get("id"), group_of(field).get("label"), group_of(field).get("variant")) + for field in panel + } + assert len(declared) == 1, ( + f"Every field under {scope} should share one group descriptor, or the " + f"panel splits: {sorted(declared)}" + ) + group_id, label, variant = declared.pop() + assert label == "Access" and variant == "access", ( + f"{scope}: expected an 'Access' panel with the access variant, got " + f"{label!r} / {variant!r}." ) - assert (admin_only[0].get("group") or {}).get("id") == "access", ( - f"{section_id}: the access control is not in the access group." + assert group_id not in group_ids, f"{scope} reuses panel id {group_id!r}." + group_ids.add(group_id) + + admin_only = [field for field in panel if field.get("key", "").endswith("_admin_only")] + assert len(admin_only) == 1, ( + f"{scope} should offer one administrators-only control like the other " + "workspace types." ) - print(f" All {len(SCOPE_SECTIONS)} scope section(s) share the same shape.") + # Visibility is not transitive in either renderer, so each field must carry + # the File Sync condition itself or the panel outlives the switch above it. + for field in panel: + dependency = field.get("depends_on") + key = field.get("key") + assert not evaluate(dependency, reader(**{scope: True})), ( + f"{key} would stay visible with File Sync off." + ) + assert not evaluate(dependency, reader(enable_file_sync=True)), ( + f"{key} would stay visible with {scope} off." + ) + + role = next( + ( + requirement + for requirement in roles_module.APP_ROLE_REQUIREMENTS + if requirement["key"] == "file_sync_personal_require_app_role" + ), + None, + ) + assert role, "The PersonalFileSyncUser requirement is missing from the registry." + assert role["section_id"] == FILE_SYNC_SECTION, ( + "'Go to setting' for PersonalFileSyncUser should land on the File Sync card: " + f"{role['section_id']!r}" + ) + assert field_in(FILE_SYNC_SECTION, "file_sync_personal_require_app_role") in anchored_to( + "enable_file_sync_personal" + ), "The app role switch should sit in the Personal workspaces Access panel." + + print(f" {len(group_ids)} Access panel(s), each anchored and gated by File Sync.") return True @@ -220,21 +344,19 @@ def test_assignment_lists_are_editable_and_gated(): cases = ( ( - "file-sync-group-section", "file_sync_allowed_group_ids", "enable_file_sync_group", "require_group_assignment_for_file_sync", ), ( - "file-sync-public-section", "file_sync_allowed_public_workspace_ids", "enable_file_sync_public", "require_public_workspace_assignment_for_file_sync", ), ) - for section_id, key, capability, requirement in cases: - field = field_in(section_id, key) + for key, capability, requirement in cases: + field = field_in(FILE_SYNC_SECTION, key) assert field, f"{key} is not declared." assert field["type"] == "id_list", field["type"] assert field.get("search_endpoint"), ( @@ -244,14 +366,28 @@ def test_assignment_lists_are_editable_and_gated(): assert field.get("results_key"), f"{key} does not say how to read the response." dependency = field["depends_on"] - assert not evaluate(dependency, {capability: True}.get), ( - f"{key} shows while its restriction is off, where it has no effect." + assert not evaluate( + dependency, reader(enable_file_sync=True, **{capability: True}) + ), f"{key} shows while its restriction is off, where it has no effect." + assert evaluate( + dependency, + reader(enable_file_sync=True, **{capability: True, requirement: True}), + ), f"{key} stays hidden with the restriction on, so it could not be set." + assert not evaluate( + dependency, reader(enable_file_sync=True, **{requirement: True}) + ), f"{key} shows while the whole workspace type is disabled." + assert not evaluate( + dependency, reader(**{capability: True, requirement: True}) + ), f"{key} shows while File Sync itself is off." + + # Sharing the restriction switch's panel is what nests the list beneath it. + restriction = field_in(FILE_SYNC_SECTION, requirement) + assert group_of(field) == group_of(restriction), ( + f"{key} should sit in the same Access panel as {requirement}, directly " + "under the switch that reveals it." ) - assert evaluate(dependency, {capability: True, requirement: True}.get), ( - f"{key} stays hidden with the restriction on, so it could not be set." - ) - assert not evaluate(dependency, {requirement: True}.get), ( - f"{key} shows while the whole scope is disabled." + assert group_of(field).get("anchor") == capability, ( + f"{key} should be in the panel anchored to {capability}." ) print(" Both assignment lists are searchable and shown only where they apply.") @@ -297,11 +433,51 @@ def test_assignment_lists_round_trip(): return True +def test_run_limits_and_source_types_are_panels(): + """Both read as panels of the File Sync card, like Run limits always has.""" + print("\nTesting the Run limits and Source types panels...") + + for key in RUN_LIMIT_KEYS: + field = field_in(FILE_SYNC_SECTION, key) + assert field, f"{key} is not declared." + group = group_of(field) + assert (group.get("id"), group.get("label")) == ("limits", "Run limits"), ( + f"{key} should be in the Run limits panel: {group}" + ) + assert not group.get("anchor"), f"{key}: Run limits apply to every workspace type." + + field = field_in(FILE_SYNC_SECTION, "file_sync_visible_source_types") + assert field, "file_sync_visible_source_types is not declared." + group = group_of(field) + assert (group.get("id"), group.get("label")) == ("source-types", "Source types"), ( + f"Source types should be its own panel called 'Source types': {group}" + ) + assert not group.get("anchor"), "Source types apply to every workspace type." + assert field.get("depends_on") == {"key": "enable_file_sync", "equals": True}, ( + field.get("depends_on") + ) + + # The panel summarises its selection while closed, which only works while the + # choice list is the panel's single field. + in_panel = [ + other + for other in section_fields(FILE_SYNC_SECTION) + if group_of(other).get("id") == "source-types" + ] + assert in_panel == [field], ( + "Source types should hold only the choice list, so its closed header can " + "say how many are selected." + ) + + print(" Run limits and Source types are panels shared by every workspace type.") + return True + + def test_unreleased_source_types_are_shown_disabled(): """Omitting them would read as the connector having been removed.""" print("\nTesting the source type options...") - field = field_in("file-sync-source-types-section", "file_sync_visible_source_types") + field = field_in(FILE_SYNC_SECTION, "file_sync_visible_source_types") assert field, "file_sync_visible_source_types is not declared." assert field["type"] == "checkbox_set", field["type"] assert field.get("default") == ["smb", "azure_files"], field.get("default") @@ -380,12 +556,14 @@ def test_the_schema_invents_nothing(): if __name__ == "__main__": tests = [ - test_the_tab_sections_match_navigation, + test_the_tab_is_one_card, test_the_redis_prerequisite_is_declared, test_the_per_run_size_limit_converts_between_gb_and_bytes, - test_the_scope_sections_share_one_shape, + test_workspace_types_nest_under_file_sync, + test_each_workspace_type_has_an_anchored_access_panel, test_assignment_lists_are_editable_and_gated, test_assignment_lists_round_trip, + test_run_limits_and_source_types_are_panels, test_unreleased_source_types_are_shown_disabled, test_every_v1_field_is_claimed, test_the_schema_invents_nothing, diff --git a/functional_tests/test_v2_admin_schema_vocabulary.py b/functional_tests/test_v2_admin_schema_vocabulary.py index 427a49312..93093cdf6 100644 --- a/functional_tests/test_v2_admin_schema_vocabulary.py +++ b/functional_tests/test_v2_admin_schema_vocabulary.py @@ -2,8 +2,9 @@ #!/usr/bin/env python3 """ Functional test for the Admin Settings schema vocabulary added for Knowledge. -Version: 0.261.122 +Version: 0.261.260 Implemented in: 0.261.084 +Anchored group coverage added in: 0.261.260 The Knowledge group needs control kinds the schema could not previously express: credentials, domain allow lists, workspace assignment lists, server-computed @@ -28,6 +29,11 @@ Domain lists and assignment lists have to round-trip through the shapes the server-rendered panes already store, or a value saved in one interface is unreadable in the other. + +anchored groups + A group may name the switch it is drawn beneath. An anchor that names nothing, + or a panel that does not depend on its switch, leaves the panel either with no + row to sit under or visible beneath a switch that is off. """ import sys @@ -332,6 +338,88 @@ def test_declared_groups_and_requires_are_well_formed(): return True +def _required_conditions(dependency): + """Conditions that must all hold for a dependency to hold, as (key, expected). + + Branches of an ``any_of`` are left out, because none of them is individually + required, and so is ``not_equals``, which says what a value is not. + """ + if not dependency: + return set() + if isinstance(dependency, list): + return set().union(*(_required_conditions(item) for item in dependency)) + if "all_of" in dependency: + return set().union( + *(_required_conditions(item) for item in dependency["all_of"]) + ) + if "any_of" in dependency or "not_equals" in dependency or not dependency.get("key"): + return set() + return {(dependency["key"], repr(dependency.get("equals", True)))} + + +def test_anchored_groups_hang_off_a_switch_above_them(): + """An anchored panel needs a switch to sit under, and must vanish with it.""" + print("\nTesting anchored groups...") + + problems = [] + anchored = 0 + for section_id, fields in fields_module.get_admin_settings_fields().items(): + anchors_by_group = {} + for index, field in enumerate(fields): + group = field.get("group") + if not isinstance(group, dict): + continue + anchor = group.get("anchor") + anchors_by_group.setdefault(group.get("id"), set()).add(anchor) + if not anchor: + continue + + anchored += 1 + name = f"{section_id}.{field.get('key') or field.get('label')}" + lead = next( + (earlier for earlier in fields[:index] if earlier.get("key") == anchor), + None, + ) + if lead is None: + problems.append( + f"{name}: anchor {anchor!r} is not a field declared earlier in " + f"{section_id}, so the panel has no row to sit under" + ) + continue + if lead.get("type") != "switch" or lead.get("readonly"): + problems.append(f"{name}: anchor {anchor!r} is not an editable switch") + if lead.get("group"): + problems.append( + f"{name}: anchor {anchor!r} is itself grouped; the renderer only " + "anchors panels under ungrouped switches" + ) + + # The panel must disappear whenever its switch is off or hidden, and + # visibility is not transitive, so it carries the switch's own + # conditions as well as the switch itself. + required = _required_conditions(field.get("depends_on")) + expected = {(anchor, repr(True))} | _required_conditions(lead.get("depends_on")) + missing = sorted(expected - required) + if missing: + problems.append( + f"{name}: does not require {missing}, so it could show while " + f"{anchor!r} is off or hidden" + ) + + for group_id, anchors in anchors_by_group.items(): + if len(anchors) > 1: + problems.append( + f"{section_id}: group {group_id!r} names different anchors " + f"{sorted(str(anchor) for anchor in anchors)}, so the panel would " + "split depending on which field is read first" + ) + + assert not problems, "\n ".join(["Malformed anchored groups:"] + problems) + + print(f" {anchored} anchored field(s) sit under a switch they depend on.") + return True + + def test_secret_keys_are_discoverable_for_redaction(): """The settings endpoint needs the list to redact before it responds.""" print("\nTesting secret key discovery...") @@ -373,6 +461,7 @@ def test_undeclared_keys_still_pass_through(): test_id_list_accepts_both_stored_and_edited_shapes, test_dependency_evaluation_supports_every_declared_shape, test_declared_groups_and_requires_are_well_formed, + test_anchored_groups_hang_off_a_switch_above_them, test_secret_keys_are_discoverable_for_redaction, test_undeclared_keys_still_pass_through, ] diff --git a/functional_tests/test_v2_admin_section_logic.ts b/functional_tests/test_v2_admin_section_logic.ts index 6a5dec64b..c1096a6d1 100644 --- a/functional_tests/test_v2_admin_section_logic.ts +++ b/functional_tests/test_v2_admin_section_logic.ts @@ -1,10 +1,11 @@ // test_v2_admin_section_logic.ts // // Runtime test for the Admin Settings section shell's presentation decisions. -// Version: 0.261.258 +// Version: 0.261.260 // Implemented in: 0.261.084 // Agents-only visual hierarchy coverage added in: 0.261.093 // Every-section presentation and schema-derived hierarchy added in: 0.261.258 +// Anchored panels and collapsed-group summaries added in: 0.261.260 // // The V2 admin surface used to render a section as a flat run of controls in declaration // order. That is fine for Appearance. It is not fine for Knowledge, where Document @@ -40,8 +41,10 @@ import { dependencyKeys, deriveFieldHierarchy, deriveSectionStatus, + describeCollapsedGroup, findCapabilityField, hasValue, + placeAnchoredGroups, shouldGroupStartOpen, } from '../application/v2_ui/src/lib/adminSections'; import { @@ -594,6 +597,226 @@ check('distinct subsection presentation preserves collapsed defaults and counts' assert.deepEqual(calls, []); }); +/** Every condition must hold, in the array shorthand the File Sync schema declares. */ +function gate(...keys: string[]): AdminFieldDependency { + return keys.map((key) => ({ key, equals: true })); +} + +function accessPanel(id: string, anchor: string) { + return { id, label: 'Access', variant: 'access' as const, anchor }; +} + +const RUN_LIMITS = { id: 'limits', label: 'Run limits', variant: 'limits' as const }; + +/** The File Sync card, shaped as `admin_settings_fields.py` declares it. */ +const FILE_SYNC_FIELDS: AdminField[] = [ + capability('enable_file_sync', { label: 'Enable File Sync' }), + { key: 'enable_file_sync_personal', type: 'switch', label: 'Personal workspaces', depends_on: gate('enable_file_sync') }, + { key: 'enable_file_sync_group', type: 'switch', label: 'Group workspaces', depends_on: gate('enable_file_sync') }, + { key: 'enable_file_sync_public', type: 'switch', label: 'Public workspaces', depends_on: gate('enable_file_sync') }, + { + key: 'file_sync_personal_admin_only', + type: 'switch', + label: 'Personal admins only', + group: accessPanel('personal-access', 'enable_file_sync_personal'), + depends_on: gate('enable_file_sync', 'enable_file_sync_personal'), + }, + { + key: 'file_sync_group_admin_only', + type: 'switch', + label: 'Group admins only', + group: accessPanel('group-access', 'enable_file_sync_group'), + depends_on: gate('enable_file_sync', 'enable_file_sync_group'), + }, + { + key: 'require_group_assignment_for_file_sync', + type: 'switch', + label: 'Restrict to assigned groups', + group: accessPanel('group-access', 'enable_file_sync_group'), + depends_on: gate('enable_file_sync', 'enable_file_sync_group'), + }, + { + key: 'file_sync_allowed_group_ids', + type: 'id_list', + label: 'Assigned groups', + group: accessPanel('group-access', 'enable_file_sync_group'), + depends_on: gate('enable_file_sync', 'enable_file_sync_group', 'require_group_assignment_for_file_sync'), + }, + { + key: 'file_sync_public_admin_only', + type: 'switch', + label: 'Public admins only', + group: accessPanel('public-access', 'enable_file_sync_public'), + depends_on: gate('enable_file_sync', 'enable_file_sync_public'), + }, + { key: 'file_sync_max_files_per_run', type: 'number', label: 'Max files', group: RUN_LIMITS, depends_on: gate('enable_file_sync') }, + { key: 'file_sync_max_concurrent_runs', type: 'number', label: 'Max runs', group: RUN_LIMITS, depends_on: gate('enable_file_sync') }, + { + key: 'file_sync_visible_source_types', + type: 'checkbox_set', + label: 'Source types offered when adding a source', + group: { id: 'source-types', label: 'Source types', variant: 'behavior' }, + options: [ + { value: 'smb', label: 'SMB Share' }, + { value: 'azure_files', label: 'Azure Files' }, + { value: 'azure_blob', label: 'Azure Blob Storage' }, + { value: 'onedrive', label: 'OneDrive', disabled: true }, + ], + depends_on: gate('enable_file_sync'), + }, +]; + +const FILE_SYNC_ON = { + enable_file_sync: true, + enable_redis_cache: true, + enable_file_sync_personal: true, + enable_file_sync_group: true, + enable_file_sync_public: false, + file_sync_visible_source_types: ['smb', 'azure_files', 'azure_blob'], +}; + +function fileSyncLabel(key: string): string | undefined { + return FILE_SYNC_FIELDS.find((field) => field.key === key)?.label; +} + +function renderFileSync(fields: AdminField[], settings: Record) { + return renderToStaticMarkup(createElement(SettingsSection, { + sectionId: 'file-sync-section', + label: 'File Sync', + groupLabel: 'Knowledge', + tabLabel: 'File Sync', + fields, + hierarchyFields: FILE_SYNC_FIELDS, + settings, + draft: {}, + renderField: (field) => createElement('span', { key: field.key }, field.label), + renderCapability: (field) => createElement('span', { key: field.key }, field.label), + })); +} + +check('a group keeps the switch it is anchored to', () => { + const groups = groupFields(FILE_SYNC_FIELDS.slice(1)); + assert.deepEqual( + groups.map((group) => [group.id, group.anchor]), + [ + ['', undefined], + ['personal-access', 'enable_file_sync_personal'], + ['group-access', 'enable_file_sync_group'], + ['public-access', 'enable_file_sync_public'], + ['limits', undefined], + ['source-types', undefined], + ], + ); +}); + +check('workspace types nest under File Sync, and an assignment list under its restriction', () => { + assert.deepEqual(emphasisOf(FILE_SYNC_FIELDS), { + enable_file_sync: 'primary', + enable_file_sync_personal: 'dependent', + enable_file_sync_group: 'dependent', + enable_file_sync_public: 'dependent', + file_sync_allowed_group_ids: 'dependent', + }); +}); + +check('an anchored group sits under its switch, or falls back carrying its name', () => { + const groups = groupFields(FILE_SYNC_FIELDS.slice(1)); + const drawn = new Set(['enable_file_sync_personal', 'enable_file_sync_group', 'enable_file_sync_public']); + + const placed = placeAnchoredGroups(groups, drawn, fileSyncLabel); + assert.deepEqual(placed.topLevel.map((group) => group.id), ['', 'limits', 'source-types']); + assert.deepEqual( + [...placed.anchored.entries()].map(([anchor, panels]) => [anchor, panels.map((group) => group.id)]), + [ + ['enable_file_sync_personal', ['personal-access']], + ['enable_file_sync_group', ['group-access']], + ['enable_file_sync_public', ['public-access']], + ], + ); + + // A search that matched the panel's settings but not its switch: three panels all + // called "Access" would be indistinguishable, so each takes its switch's name. + const searched = placeAnchoredGroups(groups, new Set(), fileSyncLabel); + assert.equal(searched.anchored.size, 0); + assert.deepEqual( + searched.topLevel.map((group) => group.label ?? group.id), + [ + '', + 'Personal workspaces · Access', + 'Group workspaces · Access', + 'Public workspaces · Access', + 'Run limits', + 'Source types', + ], + ); +}); + +check('a closed group says how much a lone choice list has chosen', () => { + const groups = groupFields(FILE_SYNC_FIELDS.slice(1)); + const sources = groups.find((group) => group.id === 'source-types') as RenderedFieldGroup; + const limits = groups.find((group) => group.id === 'limits') as RenderedFieldGroup; + + // Undeclared values are not drawn as checked, so they are not counted either. + const stored = { file_sync_visible_source_types: ['smb', 'azure_blob', 'retired_type'] }; + assert.equal(describeCollapsedGroup(sources, stored, {}), '2 selected'); + assert.equal( + describeCollapsedGroup(sources, stored, { file_sync_visible_source_types: ['smb'] }), + '1 selected', + 'an unsaved edit is what the header reports', + ); + assert.equal(describeCollapsedGroup(sources, {}, {}), '0 selected'); + assert.equal(describeCollapsedGroup(limits, {}, {}), '2 settings'); + assert.equal(describeCollapsedGroup({ ...limits, fields: [limits.fields[0]] }, {}, {}), '1 setting'); +}); + +check('the File Sync card draws each Access panel under its own workspace type', () => { + const markup = renderFileSync(FILE_SYNC_FIELDS, FILE_SYNC_ON); + + const order = [ + 'Enable File Sync', + 'Personal workspaces', + 'data-anchored-to="enable_file_sync_personal"', + 'Group workspaces', + 'data-anchored-to="enable_file_sync_group"', + 'Public workspaces', + 'Run limits', + 'Source types', + ].map((marker) => { + const index = markup.indexOf(marker); + assert.ok(index >= 0, `missing ${marker}`); + return index; + }); + assert.deepEqual([...order].sort((a, b) => a - b), order, 'panels sit between their switch and the next'); + + // Public is off, so its rules are hidden and nothing is anchored under it. + assert.doesNotMatch(markup, /data-anchored-to="enable_file_sync_public"/); + assert.equal(markup.match(/data-setting-emphasis="dependent"/g)?.length, 3); + assert.match(markup, /Personal workspaces: <\/span>Access/); + assert.match(markup, /Group workspaces: <\/span>Access/); + + // Panels start closed and say what they hold: the restriction is off, so the group + // panel has two settings, and Source types reports its selection. + assert.match(markup, />1 setting2 settings3 selected { + const markup = renderFileSync(FILE_SYNC_FIELDS, { ...FILE_SYNC_ON, enable_file_sync: false }); + assert.match(markup, /Enable File Sync/); + assert.doesNotMatch(markup, /Personal workspaces|data-anchored-to|Run limits|Source types/); +}); + +check('a panel whose switch a search filtered out falls back to the card body', () => { + const matches = FILE_SYNC_FIELDS.filter((field) => /admins only/i.test(field.label)); + const markup = renderFileSync(matches, FILE_SYNC_ON); + assert.doesNotMatch(markup, /data-anchored-to/); + assert.match(markup, /Personal workspaces · Access/); + assert.match(markup, /Group workspaces · Access/); +}); + let passed = 0; for (const [name, fn] of checks) { try { diff --git a/ui_tests/test_v2_admin_file_sync_single_card.py b/ui_tests/test_v2_admin_file_sync_single_card.py new file mode 100644 index 000000000..a097395e7 --- /dev/null +++ b/ui_tests/test_v2_admin_file_sync_single_card.py @@ -0,0 +1,196 @@ +# test_v2_admin_file_sync_single_card.py +""" +Browser coverage for the single V2 File Sync card. +Version: 0.261.260 +Implemented in: 0.261.260 + +Exercise the built application with the real File Sync field schema and +intercepted APIs. File Sync used to be five cards. Check that it is now one; that +turning it on reveals the Personal, Group and Public workspace switches nested +beneath it; that each enabled type's Access panel sits directly under its own +switch and opens to that type's rules; that the closed Source types panel reports +how many types are offered; and that the card never overflows on a phone or at a +large text size -- without writing to live settings. +""" + +import re +import sys +from pathlib import Path + +import pytest +from playwright.sync_api import expect + +# Fixtures stay under ui_tests; the import also registers Azure connect_options. +sys.path.insert(0, str(Path(__file__).resolve().parent / "fixtures")) + +from v2_admin_settings import AdminSettingsFixture, connect_options # noqa: E402,F401 + + +pytestmark = pytest.mark.ui + +SECTIONS = {"knowledge": ("file-sync-section",)} +ARTIFACTS = "v2_admin_file_sync" +WORKSPACE_TYPES = ("Personal workspaces", "Group workspaces", "Public workspaces") +RETIRED_CARDS = ( + "Visible Source Types", + "Personal Workspace Sync", + "Group Workspace Sync", + "Public Workspace Sync", +) + + +@pytest.fixture +def file_sync_ui(page): + fixture = AdminSettingsFixture(page, sections=SECTIONS) + fixture.settings.update({ + "enable_file_sync": True, + "enable_redis_cache": True, + "enable_file_sync_personal": True, + "enable_file_sync_group": True, + "enable_file_sync_public": False, + "file_sync_visible_source_types": ["smb", "azure_files", "azure_blob"], + }) + yield fixture + fixture.assert_clean() + + +def _box(locator): + box = locator.bounding_box() + assert box, "Element is not rendered" + return box + + +def _card(page): + return page.get_by_role("region", name="File Sync", exact=True) + + +def _panel(card, anchor): + """The Access panel drawn beneath the workspace type switch ``anchor``.""" + return card.locator(f'[data-anchored-to="{anchor}"]') + + +def _checkbox(scope, label): + return scope.get_by_role("checkbox", name=re.compile(f"^{re.escape(label)}")) + + +def _set_checkbox(scope, label, checked): + checkbox = _checkbox(scope, label) + if checkbox.is_checked() != checked: + scope.get_by_text(label, exact=True).click() + expect(checkbox).to_be_checked(checked=checked) + + +def test_file_sync_is_one_card_with_its_workspace_types_nested(file_sync_ui): + file_sync_ui.open(width=1440, ready_region="File Sync") + page = file_sync_ui.page + card = _card(page) + + # Only File Sync's own cards count: the fixture serves just this section's schema, + # so the Redis Cache switch it seeds is drawn under "Other capabilities". + expect(page.locator('.admin-settings-distinct[id^="file-sync"]')).to_have_count(1) + for retired in RETIRED_CARDS: + expect(page.get_by_text(retired, exact=True)).to_have_count(0) + + expect(card.locator('[data-setting-emphasis="primary"]')).to_contain_text("Enable File Sync") + nested = card.locator('[data-setting-emphasis="dependent"]') + expect(nested).to_have_count(len(WORKSPACE_TYPES)) + for index, label in enumerate(WORKSPACE_TYPES): + expect(nested.nth(index)).to_contain_text(label) + + # The panels every workspace type shares follow the types, closed. + for label in ("Run limits", "Source types"): + panel = card.get_by_role("button", name=re.compile(f"^{label}")) + expect(panel).to_have_attribute("aria-expanded", "false") + file_sync_ui.capture("card-desktop", ARTIFACTS) + + +def test_switching_file_sync_off_folds_the_card_to_its_switch(file_sync_ui): + file_sync_ui.open(width=1440, ready_region="File Sync") + card = _card(file_sync_ui.page) + + _set_checkbox(card, "Enable File Sync", False) + expect(card.locator('[data-setting-emphasis="dependent"]')).to_have_count(0) + expect(card.locator("[data-anchored-to]")).to_have_count(0) + for label in ("Run limits", "Source types"): + expect(card.get_by_role("button", name=re.compile(f"^{label}"))).to_have_count(0) + + _set_checkbox(card, "Enable File Sync", True) + expect(card.locator('[data-setting-emphasis="dependent"]')).to_have_count(len(WORKSPACE_TYPES)) + expect(card.locator("[data-anchored-to]")).to_have_count(2) + + +@pytest.mark.parametrize("width,indented", [(1440, True), (390, False)]) +def test_each_access_panel_sits_beneath_its_own_switch(file_sync_ui, width, indented): + file_sync_ui.open(width=width, ready_region="File Sync") + card = _card(file_sync_ui.page) + + # Public is off, so there are no public rules to show. + expect(_panel(card, "enable_file_sync_public")).to_have_count(0) + + group_label = _box(card.get_by_text("Group workspaces", exact=True)) + public_label = _box(card.get_by_text("Public workspaces", exact=True)) + panel = _panel(card, "enable_file_sync_group") + panel_box = _box(panel) + assert group_label["y"] < panel_box["y"] < public_label["y"], ( + "The Access panel belongs between its own switch and the next workspace type" + ) + if indented: + assert abs(panel_box["x"] - group_label["x"]) <= 2, ( + "On a wide card the panel lines up with its switch's label" + ) + else: + assert panel_box["x"] < group_label["x"] - 24, ( + "On a narrow card the panel keeps the full row" + ) + + toggle = panel.get_by_role("button", name="Group workspaces: Access") + expect(toggle).to_have_attribute("aria-expanded", "false") + expect(toggle).to_contain_text("2 settings") + toggle.focus() + file_sync_ui.page.keyboard.press("Enter") + expect(toggle).to_have_attribute("aria-expanded", "true") + expect(panel.get_by_text("Only administrators manage sources", exact=True)).to_be_visible() + + _set_checkbox(panel, "Restrict to assigned groups", True) + expect(panel.get_by_text("Assigned groups", exact=True)).to_be_visible() + # One type's rules never appear under another. + expect(_panel(card, "enable_file_sync_personal").get_by_text("Restrict to assigned groups")).to_have_count(0) + + +def test_the_closed_source_types_panel_reports_its_selection(file_sync_ui): + file_sync_ui.open(width=1440, ready_region="File Sync") + card = _card(file_sync_ui.page) + toggle = card.get_by_role("button", name=re.compile("^Source types")) + expect(toggle).to_contain_text("3 selected") + + toggle.click() + expect(card.get_by_label("OneDrive")).to_be_disabled() + card.get_by_label("Azure Blob Storage").uncheck() + toggle.click() + # The header reads the unsaved selection, not the stored one. + expect(toggle).to_contain_text("2 selected") + + +@pytest.mark.parametrize("theme", ["light", "dark"]) +@pytest.mark.parametrize("width,font_size", [(390, "m"), (390, "xl"), (1920, "xl")]) +def test_the_card_never_overflows(file_sync_ui, theme, width, font_size): + file_sync_ui.settings.update({ + "enable_file_sync_public": True, + "require_group_assignment_for_file_sync": True, + "require_public_workspace_assignment_for_file_sync": True, + }) + file_sync_ui.open(theme=theme, width=width, font_size=font_size, ready_region="File Sync") + page = file_sync_ui.page + card = _card(page) + + # Open every panel, so the deepest nesting -- an assignment list inside an Access + # panel beneath a workspace type -- is measured as well. + for toggle in card.locator(".admin-field-group > button").all(): + if toggle.get_attribute("aria-expanded") == "false": + toggle.click() + expect(card.get_by_text("Assigned public workspaces", exact=True)).to_be_visible() + + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + overflow = card.evaluate("element => element.scrollWidth - element.clientWidth") + assert overflow <= 1, f"The card overflows by {overflow}px at {width}px/{font_size}" + file_sync_ui.capture(f"responsive-{theme}-{width}-{font_size}", ARTIFACTS)