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 (
-
+
+ {contextLabel ? {`${contextLabel}: `} : null}
{group.label ?? group.id}
{!open ? (
-
- {group.fields.length}{' '}
- {group.fields.length === 1 ? 'setting' : 'settings'}
-
+ {summary}
) : null}
@@ -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}
-
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 setting);
+ assert.match(markup, />2 settings);
+ assert.match(markup, />3 selected);
+ assert.doesNotMatch(markup, /admin-switch-grid/, 'anchors keep their own rows');
+ assert.doesNotMatch(markup, /Group admins only|Source types offered/);
+});
+
+check('with File Sync off the card is just its switch', () => {
+ 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)