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 96bf8ce13..ce773bdd5 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. + A group may also name ``open_until_set``: a key in the same group whose blank value opens the group while the section's capability is on. That is for an optional connection the administrator most likely wants next -- @@ -3092,8 +3099,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": [ { @@ -3117,6 +3135,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", @@ -3184,8 +3390,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", @@ -3227,147 +3431,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 110461cb3..abb6888db 100644 --- a/application/single_app/admin_settings_nav.py +++ b/application/single_app/admin_settings_nav.py @@ -313,10 +313,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 6bd64619f..1e040ce02 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.265" +VERSION = "0.261.266" 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 8c76a5f5d..fe3fce030 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, @@ -222,11 +229,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); @@ -243,7 +259,12 @@ function FieldGroup({ } return ( -
+
@@ -342,6 +361,32 @@ export function SettingsSection({ [bodyFields, settings, draft, fieldsByKey, runtimeFlags], ); + // 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, fieldsByKey, runtimeFlags)) { + 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, fieldsByKey, runtimeFlags]); + const presentation = presentSectionStatus(status); const SectionIcon = appearance?.Icon ?? icon ?? FALLBACK_SECTION_ICON; @@ -359,6 +404,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 = ( @@ -367,8 +413,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 ( @@ -391,7 +438,14 @@ export function SettingsSection({ ) : null} -
{control}
+
+ {control} + {panels ? ( +
+ {panels.map((group) => renderGroup(group, field.label))} +
+ ) : null} +
); }; @@ -403,6 +457,23 @@ export function SettingsSection({ flowsInGrid, ); + const renderGroup = (group: RenderedFieldGroup, contextLabel?: string) => ( + readSectionValue(settings, draft, key, fieldsByKey), + )} + forceExpanded={forceExpanded} + renderFields={renderFields} + summary={describeCollapsedGroup(group, settings, draft)} + contextLabel={contextLabel} + /> + ); + return ( ) : null} - {groups.map((group) => ( - readSectionValue(settings, draft, key, fieldsByKey), - )} - forceExpanded={forceExpanded} - renderFields={renderFields} - /> - ))} + {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 0f1a41450..d5be3f98a 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; /** * A key in this group whose blank value opens the group while the section's * capability is on. @@ -507,6 +516,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; /** A key whose blank value opens the group while the capability is on. */ openUntilSet?: string; fields: AdminField[]; @@ -536,6 +547,7 @@ export function groupFields(fields: AdminField[]): RenderedFieldGroup[] { variant: declared?.variant, help: declared?.help, collapsed: Boolean(field.collapsed), + anchor: declared?.anchor, openUntilSet: declared?.open_until_set, fields: [], }; diff --git a/application/v2_ui/src/lib/adminSections.ts b/application/v2_ui/src/lib/adminSections.ts index 496866c39..a7ddf5d24 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, readSettingValue, type AdminField, type AdminFieldDependency, @@ -344,3 +346,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 5c9389e43..b9e7a70f7 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 15c53e613..4f0ded923 100644 --- a/docs/admin/knowledge.md +++ b/docs/admin/knowledge.md @@ -402,55 +402,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..8855ac25a --- /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.266** + +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 a5ce5ff56..02231a708 100644 --- a/docs/explanation/features/V2_ADMIN_KNOWLEDGE_SETTINGS.md +++ b/docs/explanation/features/V2_ADMIN_KNOWLEDGE_SETTINGS.md @@ -56,7 +56,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. | @@ -149,19 +149,22 @@ that the two interfaces order or group them identically. The intentional differe why to leave it on. The classic page dropped its short-lived control, which never saved, in late 2025. Until the switch was declared, the fallback scan matched "search" and filed it under Web Search as a bare toggle. Both keys are recorded in `V2_ONLY_FIELDS`. +- File Sync drawn as one card (since 0.261.266): 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_search_result_cache_admin_setting.py` | Search result cache placement, defaults against the runtime, lifetime bounds, V2-only record | | `ui_tests/test_v2_admin_search_result_cache.py` | The cache group renders under Azure AI Search, hides its lifetime while off, and saves through the normalizer | | `test_model_vision_capability_resolution.py` | Three-tier resolution and its precedence | 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..90e12affb 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.266 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..9027f5227 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.266 Implemented in: 0.261.084 +Consolidated into one card in: 0.261.266 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.266") 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 64e137318..4061f6c99 100644 --- a/functional_tests/test_v2_admin_schema_vocabulary.py +++ b/functional_tests/test_v2_admin_schema_vocabulary.py @@ -2,9 +2,10 @@ #!/usr/bin/env python3 """ Functional test for the Admin Settings schema vocabulary added for Knowledge. -Version: 0.261.265 +Version: 0.261.266 Implemented in: 0.261.084 Runtime flag evaluation, open_until_set and on_enable added in: 0.261.265 +Anchored group coverage added in: 0.261.266 The Knowledge group needs control kinds the schema could not previously express: credentials, domain allow lists, workspace assignment lists, server-computed @@ -29,6 +30,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 @@ -348,6 +354,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_on_enable_effects_are_well_formed(): """A companion naming a missing key or option would be saved and never read.""" print("\nTesting on_enable descriptors...") @@ -465,6 +553,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_on_enable_effects_are_well_formed, test_runtime_flag_conditions_follow_the_flags_given, test_secret_keys_are_discoverable_for_redaction, diff --git a/functional_tests/test_v2_admin_section_logic.ts b/functional_tests/test_v2_admin_section_logic.ts index ddbe852c1..966efb1d6 100644 --- a/functional_tests/test_v2_admin_section_logic.ts +++ b/functional_tests/test_v2_admin_section_logic.ts @@ -1,12 +1,13 @@ // test_v2_admin_section_logic.ts // // Runtime test for the Admin Settings section shell's presentation decisions. -// Version: 0.261.265 +// Version: 0.261.266 // 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 // Values saved at a nested path covered in: 0.261.260 // Enhanced extraction engine, open_until_set and on_enable coverage added in: 0.261.265 +// Anchored panels and collapsed-group summaries added in: 0.261.266 // // 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 @@ -43,8 +44,10 @@ import { dependencyKeys, deriveFieldHierarchy, deriveSectionStatus, + describeCollapsedGroup, findCapabilityField, hasValue, + placeAnchoredGroups, shouldGroupStartOpen, } from '../application/v2_ui/src/lib/adminSections'; import { @@ -601,6 +604,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/); +}); + /** * A connection saved inside a container, declared the way Web Search's is. * 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..3811f98da --- /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.266 +Implemented in: 0.261.266 + +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)