feat(webui): standardize discard and table actions
This commit is contained in:
@@ -46,6 +46,10 @@ contestability, responsibility, and traceability at the point of action.
|
||||
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
|
||||
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
|
||||
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
|
||||
| UX-023 | `FieldLabel` is the standard label/help surface for every field that is not self-explanatory. Any field rendered without it must be recorded in the omission register below, including its accessible-name source and rationale. Users may hide inline help markers through their persisted interface preference; the field label itself remains visible. | Accepted | All Core and module forms |
|
||||
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
|
||||
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
|
||||
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
|
||||
|
||||
## Confirmed Implementation Decisions
|
||||
|
||||
@@ -191,9 +195,10 @@ explicitly retains the exception.
|
||||
Decision: Scheduling requests provide a concrete reference application of the
|
||||
universal placement and component rules.
|
||||
|
||||
- The `Scheduling requests` header owns one `Add` action.
|
||||
- Its left panel is used for the creation view only, not as a permanent second
|
||||
creation affordance.
|
||||
- The persistent left panel stacks `My scheduling requests` and `Scheduling
|
||||
requests for me`; it is list context, not a second creation affordance.
|
||||
- The left panel's `Scheduling requests` header owns one `Add` action. It opens
|
||||
the shared view/create/edit surface in the right main panel.
|
||||
- Basic information, Calendar integration, candidate slots, and participants
|
||||
use the central `Card` component as four logical sections.
|
||||
- Candidate slots and participants use the central `DataGrid`, including its
|
||||
@@ -207,6 +212,50 @@ universal placement and component rules.
|
||||
Equivalent list/create/edit surfaces use the same underlying rules. These are
|
||||
not Scheduling-local component variants.
|
||||
|
||||
### DUE-011: Field Help, Discard, And Table Action Contracts
|
||||
|
||||
Decision: the central components own these interactions; modules compose them
|
||||
instead of reproducing their behavior.
|
||||
|
||||
- `FormField` and `ToggleSwitch` already render `FieldLabel`. Direct field
|
||||
compositions use `FieldLabel` explicitly when the meaning or limitation is
|
||||
not self-explanatory.
|
||||
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
||||
applying `ui-hide-help-hints` at the document root.
|
||||
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
|
||||
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
|
||||
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
|
||||
the same shared unsaved-changes dialog. A browser tab/window unload remains a
|
||||
browser-controlled confirmation because browsers do not permit a custom
|
||||
modal at that boundary.
|
||||
- `TableActionGroup` receives the table's stable action set. Use `disabled` and
|
||||
`disabledReason` for row state; omit an action only when that action does not
|
||||
belong to the table. `minimumSlots` reserves trailing positions for an empty
|
||||
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
|
||||
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
||||
`DismissibleAlert`. They never fall back to `window.alert`.
|
||||
|
||||
#### FieldLabel Omission Register
|
||||
|
||||
Every Core field surface that intentionally does not render `FieldLabel` is
|
||||
listed here. Module repositories keep an equivalent register in their durable
|
||||
UI documentation until a central cross-repository audit is available.
|
||||
|
||||
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
|
||||
| --- | --- | --- |
|
||||
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
|
||||
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
|
||||
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
|
||||
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
|
||||
| `EmailAddressInput` compact Name and Email fields | These two conventional fields are self-explanatory in the compact address popover; richer address guidance belongs to the enclosing field. | Visible native labels; the free-form editor also has a descriptive `aria-label`. |
|
||||
| `DataGrid` page-size, filter, and inline cell editors | The surrounding column header/filter heading supplies field context; repeating a labelled help marker in every cell would add noise. | Column header, filter heading/native label, or generated cell `aria-label`. |
|
||||
| Retention-policy value controls | `PolicyRow` owns the field label, help, effective value, and provenance for its control. | The containing `PolicyRow` label/help contract. |
|
||||
|
||||
#### Alert Exception Register
|
||||
|
||||
No `window.alert` or global `alert` exception is authorized.
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
| Phase | Scope | Output |
|
||||
@@ -277,6 +326,14 @@ Every new or changed admin/configuration surface should answer:
|
||||
- Are logical sections, tabular collections, boolean settings, and repeatable
|
||||
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
|
||||
structured row per person respectively?
|
||||
- Does every non-self-explanatory field use `FieldLabel`, and is every omission
|
||||
recorded with its rationale and accessible-name source?
|
||||
- Do explicit Discard and dirty navigation use the shared unsaved-changes
|
||||
registration/dialog rather than a page-local confirmation?
|
||||
- Does every row retain the table's action set in the same order, disabling
|
||||
unavailable actions and reserving the same empty-row slots?
|
||||
- Is feedback rendered with a central dialog/alert component, with no
|
||||
unauthorized `window.alert` or global `alert` call?
|
||||
- If automation is involved, can the user see the trigger, system actor,
|
||||
observed effects, and failure/manual-intervention state?
|
||||
- Are technical details available without being the first thing the user sees?
|
||||
|
||||
@@ -26,6 +26,11 @@ type UnsavedChangesContextValue = {
|
||||
hasUnsavedChanges: boolean;
|
||||
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
|
||||
requestNavigation: (action: UnsavedNavigationAction) => void;
|
||||
/**
|
||||
* Route an explicit Discard button through the same confirmation used for
|
||||
* dirty navigation. The action runs after either saving or discarding.
|
||||
*/
|
||||
requestDiscard: (action: UnsavedNavigationAction) => void;
|
||||
};
|
||||
|
||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||
@@ -72,6 +77,10 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
||||
setPendingAction(() => action);
|
||||
}, []);
|
||||
|
||||
const requestDiscard = useCallback((action: UnsavedNavigationAction) => {
|
||||
requestNavigation(action);
|
||||
}, [requestNavigation]);
|
||||
|
||||
useEffect(() => {
|
||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||
const active = registrationRef.current;
|
||||
@@ -150,8 +159,9 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
||||
const value = useMemo<UnsavedChangesContextValue>(() => ({
|
||||
hasUnsavedChanges,
|
||||
registerUnsavedChanges,
|
||||
requestNavigation
|
||||
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]);
|
||||
requestNavigation,
|
||||
requestDiscard
|
||||
}), [hasUnsavedChanges, registerUnsavedChanges, requestDiscard, requestNavigation]);
|
||||
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={value}>
|
||||
@@ -185,7 +195,8 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
||||
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
|
||||
hasUnsavedChanges: false,
|
||||
registerUnsavedChanges: () => () => undefined,
|
||||
requestNavigation: (action) => action()
|
||||
requestNavigation: (action) => action(),
|
||||
requestDiscard: (action) => action()
|
||||
};
|
||||
|
||||
export function useUnsavedChanges() {
|
||||
|
||||
@@ -106,6 +106,8 @@ type DataGridProps<T> = {
|
||||
export type DataGridRowActionsProps = {
|
||||
disabled?: boolean;
|
||||
removeDisabled?: boolean;
|
||||
/** Set to false only when row ordering is not part of this table's action set. */
|
||||
reorderable?: boolean;
|
||||
onAddBelow: () => void;
|
||||
onRemove: () => void;
|
||||
onMoveUp?: () => void;
|
||||
@@ -858,6 +860,7 @@ export function DataGridPaginationBar({
|
||||
export function DataGridRowActions({
|
||||
disabled = false,
|
||||
removeDisabled = false,
|
||||
reorderable = true,
|
||||
onAddBelow,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
@@ -879,20 +882,18 @@ export function DataGridRowActions({
|
||||
disabled,
|
||||
onClick: onAddBelow
|
||||
},
|
||||
{
|
||||
reorderable && {
|
||||
id: "move-up",
|
||||
label: moveUpLabel,
|
||||
icon: <ArrowUp size={16} aria-hidden="true" />,
|
||||
applicable: Boolean(onMoveUp),
|
||||
disabled,
|
||||
disabled: disabled || !onMoveUp,
|
||||
onClick: () => onMoveUp?.()
|
||||
},
|
||||
{
|
||||
reorderable && {
|
||||
id: "move-down",
|
||||
label: moveDownLabel,
|
||||
icon: <ArrowDown size={16} aria-hidden="true" />,
|
||||
applicable: Boolean(onMoveDown),
|
||||
disabled,
|
||||
disabled: disabled || !onMoveDown,
|
||||
onClick: () => onMoveDown?.()
|
||||
},
|
||||
{
|
||||
@@ -910,16 +911,18 @@ export function DataGridRowActions({
|
||||
|
||||
export function DataGridEmptyAction({
|
||||
disabled = false,
|
||||
reorderable = true,
|
||||
onAdd,
|
||||
label = "i18n:govoplan-core.add_first_row.c82c15f2"
|
||||
|
||||
|
||||
|
||||
|
||||
}: {disabled?: boolean;onAdd: () => void;label?: string;}) {
|
||||
}: {disabled?: boolean;reorderable?: boolean;onAdd: () => void;label?: string;}) {
|
||||
return (
|
||||
<TableActionGroup
|
||||
className="data-grid-row-actions data-grid-empty-row-actions"
|
||||
minimumSlots={reorderable ? 4 : 2}
|
||||
actions={[{
|
||||
id: "add",
|
||||
label,
|
||||
|
||||
@@ -7,9 +7,14 @@ export type TableActionDefinition = {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
/** Set to false when the action does not apply to this row. */
|
||||
/**
|
||||
* @deprecated Use `disabled` for a row-level unavailable state and omit the
|
||||
* action from the array only when it is not part of this table's action set.
|
||||
* A false value is retained as a disabled, visible action for compatibility.
|
||||
*/
|
||||
applicable?: boolean;
|
||||
disabled?: boolean;
|
||||
disabledReason?: ReactNode;
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
};
|
||||
|
||||
@@ -17,12 +22,14 @@ export type TableActionGroupProps = {
|
||||
actions: readonly (TableActionDefinition | false | null | undefined)[];
|
||||
label?: string;
|
||||
className?: string;
|
||||
/** Reserve trailing action slots so controls keep the same column position. */
|
||||
minimumSlots?: number;
|
||||
};
|
||||
|
||||
function isApplicableAction(
|
||||
function isDefinedAction(
|
||||
action: TableActionDefinition | false | null | undefined
|
||||
): action is TableActionDefinition {
|
||||
return Boolean(action && action.applicable !== false);
|
||||
return Boolean(action);
|
||||
}
|
||||
|
||||
export function runTableAction(
|
||||
@@ -36,19 +43,24 @@ export function runTableAction(
|
||||
/**
|
||||
* The standard row-level action surface for tables and DataGrids.
|
||||
*
|
||||
* Callers describe only actions that make sense for the row. An action may be
|
||||
* disabled when it applies but is temporarily unavailable; actions marked as
|
||||
* not applicable are omitted entirely. Labels remain available to assistive
|
||||
* technology and as native tooltips while the visible controls stay icon-only.
|
||||
* Callers define the action set for the whole table. An action that cannot be
|
||||
* used for one row stays visible and disabled; an action that is structurally
|
||||
* irrelevant to the table is omitted from the array. Labels remain available
|
||||
* to assistive technology and as native tooltips while the visible controls
|
||||
* stay icon-only. `minimumSlots` reserves trailing positions for empty-state
|
||||
* rows, keeping their first action aligned with ordinary rows.
|
||||
*/
|
||||
export default function TableActionGroup({
|
||||
actions,
|
||||
label = "i18n:govoplan-core.actions.c3cd636a",
|
||||
className = ""
|
||||
className = "",
|
||||
minimumSlots = 0
|
||||
}: TableActionGroupProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const applicableActions = actions.filter(isApplicableAction);
|
||||
if (applicableActions.length === 0) return null;
|
||||
const definedActions = actions.filter(isDefinedAction);
|
||||
if (definedActions.length === 0) return null;
|
||||
const normalizedMinimumSlots = Number.isFinite(minimumSlots) ? Math.max(0, Math.floor(minimumSlots)) : 0;
|
||||
const placeholderCount = Math.max(0, normalizedMinimumSlots - definedActions.length);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -56,7 +68,7 @@ export default function TableActionGroup({
|
||||
role="group"
|
||||
aria-label={translateText(label)}
|
||||
>
|
||||
{applicableActions.map((action) => {
|
||||
{definedActions.map((action) => {
|
||||
const translatedLabel = translateText(action.label);
|
||||
return (
|
||||
<Button
|
||||
@@ -66,7 +78,8 @@ export default function TableActionGroup({
|
||||
className="table-action-button"
|
||||
aria-label={translatedLabel}
|
||||
title={translatedLabel}
|
||||
disabled={action.disabled}
|
||||
disabled={action.disabled || action.applicable === false}
|
||||
disabledReason={action.disabledReason}
|
||||
onClick={(event) => runTableAction(event, action.onClick)}
|
||||
>
|
||||
<span className="table-action-icon" aria-hidden="true">
|
||||
@@ -75,6 +88,13 @@ export default function TableActionGroup({
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{Array.from({ length: placeholderCount }, (_unused, index) => (
|
||||
<span
|
||||
key={`reserved-action-slot-${index}`}
|
||||
className="table-action-placeholder"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,15 @@ export default function SettingsPage({
|
||||
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
|
||||
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
|
||||
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, [
|
||||
"mail_servers:read",
|
||||
"mail_servers:write",
|
||||
"mail_servers:manage_credentials",
|
||||
"mail:profile:write_own",
|
||||
"mail:secret:manage_own",
|
||||
"admin:policies:read",
|
||||
"admin:policies:write"
|
||||
]);
|
||||
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
||||
const contributedSections = useMemo(
|
||||
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
||||
@@ -319,8 +327,8 @@ export default function SettingsPage({
|
||||
scopeId={auth.user.id}
|
||||
profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798"
|
||||
policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23"
|
||||
canWriteProfiles={hasScope(auth, "mail_servers:write")}
|
||||
canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")}
|
||||
canWriteProfiles={hasAnyScope(auth, ["mail_servers:write", "mail:profile:write_own"])}
|
||||
canManageCredentials={hasAnyScope(auth, ["mail_servers:manage_credentials", "mail:secret:manage_own"])}
|
||||
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />
|
||||
|
||||
}
|
||||
|
||||
@@ -686,6 +686,15 @@
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.table-action-placeholder {
|
||||
display: block;
|
||||
flex: 0 0 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.data-grid-empty-message {
|
||||
color: var(--muted);
|
||||
min-height: 64px;
|
||||
|
||||
@@ -32,18 +32,23 @@ const emptyAction = renderToStaticMarkup(
|
||||
);
|
||||
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
|
||||
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
|
||||
assertEqual((emptyAction.match(/table-action-placeholder/g) ?? []).length, 3, "empty actions reserve the remaining ordinary row positions");
|
||||
|
||||
const contextActions = renderToStaticMarkup(
|
||||
<TableActionGroup
|
||||
actions={[
|
||||
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop },
|
||||
{ id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop },
|
||||
{ id: "remove", label: "Remove", icon: <span>R</span>, disabled: true, onClick: noop }
|
||||
{ id: "remove", label: "Remove", icon: <span>R</span>, disabledReason: "Permission denied", onClick: noop }
|
||||
]}
|
||||
minimumSlots={4}
|
||||
/>
|
||||
);
|
||||
assertEqual(buttonCount(contextActions), 2, "table action groups omit actions that do not apply");
|
||||
assertEqual(nonSubmittingButtonCount(contextActions), 2, "table action groups do not submit an enclosing form");
|
||||
assertEqual(buttonCount(contextActions), 3, "row-level unavailable actions remain visible");
|
||||
assertEqual(nonSubmittingButtonCount(contextActions), 3, "table action groups do not submit an enclosing form");
|
||||
assertEqual((contextActions.match(/disabled=""/g) ?? []).length, 2, "unavailable actions are disabled");
|
||||
assertEqual((contextActions.match(/table-action-placeholder/g) ?? []).length, 1, "minimum action slots reserve trailing positions");
|
||||
assertEqual(contextActions.includes("disabled-action-tooltip"), true, "disabled table actions can explain their unavailable state");
|
||||
assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label");
|
||||
assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip");
|
||||
assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative");
|
||||
@@ -58,10 +63,16 @@ assertEqual(propagationStopped, true, "table actions do not trigger clickable ro
|
||||
assertEqual(actionCalled, true, "table actions still invoke their handler");
|
||||
|
||||
const noReorderActions = renderToStaticMarkup(
|
||||
<DataGridRowActions onAddBelow={noop} onRemove={noop} />
|
||||
<DataGridRowActions reorderable={false} onAddBelow={noop} onRemove={noop} />
|
||||
);
|
||||
assertEqual(buttonCount(noReorderActions), 2, "row actions omit reorder controls when ordering does not apply");
|
||||
|
||||
const unavailableReorderActions = renderToStaticMarkup(
|
||||
<DataGridRowActions onAddBelow={noop} onRemove={noop} />
|
||||
);
|
||||
assertEqual(buttonCount(unavailableReorderActions), 4, "ordered rows retain unavailable boundary controls");
|
||||
assertEqual((unavailableReorderActions.match(/disabled=""/g) ?? []).length, 2, "unavailable boundary controls are disabled");
|
||||
|
||||
const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission denied">Save</Button>);
|
||||
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
|
||||
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
|
||||
|
||||
Reference in New Issue
Block a user