Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion application/single_app/admin_app_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
351 changes: 209 additions & 142 deletions application/single_app/admin_settings_fields.py

Large diffs are not rendered by default.

4 changes: 0 additions & 4 deletions application/single_app/admin_settings_nav.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
],
},
],
Expand Down
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 80 additions & 22 deletions application/v2_ui/src/components/admin/SettingsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,7 +46,9 @@ import {
collectRequirements,
computeSectionStatus,
deriveFieldHierarchy,
describeCollapsedGroup,
findCapabilityField,
placeAnchoredGroups,
readSectionValue,
shouldGroupStartOpen,
type FieldEmphasis,
Expand Down Expand Up @@ -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);

Expand All @@ -243,7 +259,12 @@ function FieldGroup({
}

return (
<div className="admin-field-group mt-3 rounded-xl border border-edge-strong bg-surface-solid">
<div
className={clsx(
'admin-field-group rounded-xl border border-edge-strong bg-surface-solid',
contextLabel ? 'mt-1' : 'mt-3',
)}
>
<button
type="button"
aria-expanded={open}
Expand All @@ -262,13 +283,11 @@ function FieldGroup({
)}
/>
<span className="min-w-0 text-sm font-semibold text-text-1">
{contextLabel ? <span className="sr-only">{`${contextLabel}: `}</span> : null}
{group.label ?? group.id}
</span>
{!open ? (
<span className="ml-auto shrink-0 text-xs text-text-3">
{group.fields.length}{' '}
{group.fields.length === 1 ? 'setting' : 'settings'}
</span>
<span className="ml-auto shrink-0 text-xs text-text-3">{summary}</span>
) : null}
</button>

Expand Down Expand Up @@ -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<string>();
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<string, string>();
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;

Expand All @@ -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 = (
Expand All @@ -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 (
Expand All @@ -391,7 +438,14 @@ export function SettingsSection({
<FieldIcon size={16} />
</span>
) : null}
<div className="min-w-0 flex-1">{control}</div>
<div className="min-w-0 flex-1">
{control}
{panels ? (
<div className="admin-anchored-groups pb-2" data-anchored-to={field.key}>
{panels.map((group) => renderGroup(group, field.label))}
</div>
) : null}
</div>
</div>
);
};
Expand All @@ -403,6 +457,23 @@ export function SettingsSection({
flowsInGrid,
);

const renderGroup = (group: RenderedFieldGroup, contextLabel?: string) => (
<FieldGroup
key={group.id || '__ungrouped'}
group={group}
startOpen={shouldGroupStartOpen(
group,
status,
capabilityOn,
(key) => readSectionValue(settings, draft, key, fieldsByKey),
)}
forceExpanded={forceExpanded}
renderFields={renderFields}
summary={describeCollapsedGroup(group, settings, draft)}
contextLabel={contextLabel}
/>
);

return (
<GlassPanel
id={sectionId}
Expand Down Expand Up @@ -464,20 +535,7 @@ export function SettingsSection({
</div>
) : null}

{groups.map((group) => (
<FieldGroup
key={group.id || '__ungrouped'}
group={group}
startOpen={shouldGroupStartOpen(
group,
status,
capabilityOn,
(key) => readSectionValue(settings, draft, key, fieldsByKey),
)}
forceExpanded={forceExpanded}
renderFields={renderFields}
/>
))}
{placement.topLevel.map((group) => renderGroup(group))}

{children}
</div>
Expand Down
12 changes: 12 additions & 0 deletions application/v2_ui/src/lib/adminFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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: [],
};
Expand Down
73 changes: 73 additions & 0 deletions application/v2_ui/src/lib/adminSections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
import {
asBoolean,
asString,
asStringArray,
evaluateDependency,
evaluateSectionStatus,
readFieldGroup,
readFieldValue,
readSettingValue,
type AdminField,
type AdminFieldDependency,
Expand Down Expand Up @@ -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<string, RenderedFieldGroup[]>;
}

/**
* 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<string>,
labelOf: (key: string) => string | undefined,
): GroupPlacement {
const topLevel: RenderedFieldGroup[] = [];
const anchored = new Map<string, RenderedFieldGroup[]>();

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'}`;
}
7 changes: 7 additions & 0 deletions application/v2_ui/src/styles/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
18 changes: 1 addition & 17 deletions docs/_data/app_surface.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading