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-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-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-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
|
## Confirmed Implementation Decisions
|
||||||
|
|
||||||
@@ -191,9 +195,10 @@ explicitly retains the exception.
|
|||||||
Decision: Scheduling requests provide a concrete reference application of the
|
Decision: Scheduling requests provide a concrete reference application of the
|
||||||
universal placement and component rules.
|
universal placement and component rules.
|
||||||
|
|
||||||
- The `Scheduling requests` header owns one `Add` action.
|
- The persistent left panel stacks `My scheduling requests` and `Scheduling
|
||||||
- Its left panel is used for the creation view only, not as a permanent second
|
requests for me`; it is list context, not a second creation affordance.
|
||||||
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
|
- Basic information, Calendar integration, candidate slots, and participants
|
||||||
use the central `Card` component as four logical sections.
|
use the central `Card` component as four logical sections.
|
||||||
- Candidate slots and participants use the central `DataGrid`, including its
|
- 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
|
Equivalent list/create/edit surfaces use the same underlying rules. These are
|
||||||
not Scheduling-local component variants.
|
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
|
## Implementation Sequence
|
||||||
|
|
||||||
| Phase | Scope | Output |
|
| 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
|
- Are logical sections, tabular collections, boolean settings, and repeatable
|
||||||
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
|
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
|
||||||
structured row per person respectively?
|
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,
|
- If automation is involved, can the user see the trigger, system actor,
|
||||||
observed effects, and failure/manual-intervention state?
|
observed effects, and failure/manual-intervention state?
|
||||||
- Are technical details available without being the first thing the user sees?
|
- Are technical details available without being the first thing the user sees?
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ type UnsavedChangesContextValue = {
|
|||||||
hasUnsavedChanges: boolean;
|
hasUnsavedChanges: boolean;
|
||||||
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
|
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
|
||||||
requestNavigation: (action: UnsavedNavigationAction) => 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);
|
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||||
@@ -72,6 +77,10 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
|||||||
setPendingAction(() => action);
|
setPendingAction(() => action);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const requestDiscard = useCallback((action: UnsavedNavigationAction) => {
|
||||||
|
requestNavigation(action);
|
||||||
|
}, [requestNavigation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||||
const active = registrationRef.current;
|
const active = registrationRef.current;
|
||||||
@@ -150,8 +159,9 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
|||||||
const value = useMemo<UnsavedChangesContextValue>(() => ({
|
const value = useMemo<UnsavedChangesContextValue>(() => ({
|
||||||
hasUnsavedChanges,
|
hasUnsavedChanges,
|
||||||
registerUnsavedChanges,
|
registerUnsavedChanges,
|
||||||
requestNavigation
|
requestNavigation,
|
||||||
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]);
|
requestDiscard
|
||||||
|
}), [hasUnsavedChanges, registerUnsavedChanges, requestDiscard, requestNavigation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnsavedChangesContext.Provider value={value}>
|
<UnsavedChangesContext.Provider value={value}>
|
||||||
@@ -185,7 +195,8 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
|||||||
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
|
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
registerUnsavedChanges: () => () => undefined,
|
registerUnsavedChanges: () => () => undefined,
|
||||||
requestNavigation: (action) => action()
|
requestNavigation: (action) => action(),
|
||||||
|
requestDiscard: (action) => action()
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useUnsavedChanges() {
|
export function useUnsavedChanges() {
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ type DataGridProps<T> = {
|
|||||||
export type DataGridRowActionsProps = {
|
export type DataGridRowActionsProps = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
removeDisabled?: boolean;
|
removeDisabled?: boolean;
|
||||||
|
/** Set to false only when row ordering is not part of this table's action set. */
|
||||||
|
reorderable?: boolean;
|
||||||
onAddBelow: () => void;
|
onAddBelow: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onMoveUp?: () => void;
|
onMoveUp?: () => void;
|
||||||
@@ -858,6 +860,7 @@ export function DataGridPaginationBar({
|
|||||||
export function DataGridRowActions({
|
export function DataGridRowActions({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
removeDisabled = false,
|
removeDisabled = false,
|
||||||
|
reorderable = true,
|
||||||
onAddBelow,
|
onAddBelow,
|
||||||
onRemove,
|
onRemove,
|
||||||
onMoveUp,
|
onMoveUp,
|
||||||
@@ -879,20 +882,18 @@ export function DataGridRowActions({
|
|||||||
disabled,
|
disabled,
|
||||||
onClick: onAddBelow
|
onClick: onAddBelow
|
||||||
},
|
},
|
||||||
{
|
reorderable && {
|
||||||
id: "move-up",
|
id: "move-up",
|
||||||
label: moveUpLabel,
|
label: moveUpLabel,
|
||||||
icon: <ArrowUp size={16} aria-hidden="true" />,
|
icon: <ArrowUp size={16} aria-hidden="true" />,
|
||||||
applicable: Boolean(onMoveUp),
|
disabled: disabled || !onMoveUp,
|
||||||
disabled,
|
|
||||||
onClick: () => onMoveUp?.()
|
onClick: () => onMoveUp?.()
|
||||||
},
|
},
|
||||||
{
|
reorderable && {
|
||||||
id: "move-down",
|
id: "move-down",
|
||||||
label: moveDownLabel,
|
label: moveDownLabel,
|
||||||
icon: <ArrowDown size={16} aria-hidden="true" />,
|
icon: <ArrowDown size={16} aria-hidden="true" />,
|
||||||
applicable: Boolean(onMoveDown),
|
disabled: disabled || !onMoveDown,
|
||||||
disabled,
|
|
||||||
onClick: () => onMoveDown?.()
|
onClick: () => onMoveDown?.()
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -910,16 +911,18 @@ export function DataGridRowActions({
|
|||||||
|
|
||||||
export function DataGridEmptyAction({
|
export function DataGridEmptyAction({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
reorderable = true,
|
||||||
onAdd,
|
onAdd,
|
||||||
label = "i18n:govoplan-core.add_first_row.c82c15f2"
|
label = "i18n:govoplan-core.add_first_row.c82c15f2"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}: {disabled?: boolean;onAdd: () => void;label?: string;}) {
|
}: {disabled?: boolean;reorderable?: boolean;onAdd: () => void;label?: string;}) {
|
||||||
return (
|
return (
|
||||||
<TableActionGroup
|
<TableActionGroup
|
||||||
className="data-grid-row-actions data-grid-empty-row-actions"
|
className="data-grid-row-actions data-grid-empty-row-actions"
|
||||||
|
minimumSlots={reorderable ? 4 : 2}
|
||||||
actions={[{
|
actions={[{
|
||||||
id: "add",
|
id: "add",
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ export type TableActionDefinition = {
|
|||||||
label: string;
|
label: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
onClick: () => void;
|
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;
|
applicable?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
disabledReason?: ReactNode;
|
||||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,12 +22,14 @@ export type TableActionGroupProps = {
|
|||||||
actions: readonly (TableActionDefinition | false | null | undefined)[];
|
actions: readonly (TableActionDefinition | false | null | undefined)[];
|
||||||
label?: string;
|
label?: string;
|
||||||
className?: 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: TableActionDefinition | false | null | undefined
|
||||||
): action is TableActionDefinition {
|
): action is TableActionDefinition {
|
||||||
return Boolean(action && action.applicable !== false);
|
return Boolean(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runTableAction(
|
export function runTableAction(
|
||||||
@@ -36,19 +43,24 @@ export function runTableAction(
|
|||||||
/**
|
/**
|
||||||
* The standard row-level action surface for tables and DataGrids.
|
* The standard row-level action surface for tables and DataGrids.
|
||||||
*
|
*
|
||||||
* Callers describe only actions that make sense for the row. An action may be
|
* Callers define the action set for the whole table. An action that cannot be
|
||||||
* disabled when it applies but is temporarily unavailable; actions marked as
|
* used for one row stays visible and disabled; an action that is structurally
|
||||||
* not applicable are omitted entirely. Labels remain available to assistive
|
* irrelevant to the table is omitted from the array. Labels remain available
|
||||||
* technology and as native tooltips while the visible controls stay icon-only.
|
* 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({
|
export default function TableActionGroup({
|
||||||
actions,
|
actions,
|
||||||
label = "i18n:govoplan-core.actions.c3cd636a",
|
label = "i18n:govoplan-core.actions.c3cd636a",
|
||||||
className = ""
|
className = "",
|
||||||
|
minimumSlots = 0
|
||||||
}: TableActionGroupProps) {
|
}: TableActionGroupProps) {
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const applicableActions = actions.filter(isApplicableAction);
|
const definedActions = actions.filter(isDefinedAction);
|
||||||
if (applicableActions.length === 0) return null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -56,7 +68,7 @@ export default function TableActionGroup({
|
|||||||
role="group"
|
role="group"
|
||||||
aria-label={translateText(label)}
|
aria-label={translateText(label)}
|
||||||
>
|
>
|
||||||
{applicableActions.map((action) => {
|
{definedActions.map((action) => {
|
||||||
const translatedLabel = translateText(action.label);
|
const translatedLabel = translateText(action.label);
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -66,7 +78,8 @@ export default function TableActionGroup({
|
|||||||
className="table-action-button"
|
className="table-action-button"
|
||||||
aria-label={translatedLabel}
|
aria-label={translatedLabel}
|
||||||
title={translatedLabel}
|
title={translatedLabel}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled || action.applicable === false}
|
||||||
|
disabledReason={action.disabledReason}
|
||||||
onClick={(event) => runTableAction(event, action.onClick)}
|
onClick={(event) => runTableAction(event, action.onClick)}
|
||||||
>
|
>
|
||||||
<span className="table-action-icon" aria-hidden="true">
|
<span className="table-action-icon" aria-hidden="true">
|
||||||
@@ -75,6 +88,13 @@ export default function TableActionGroup({
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{Array.from({ length: placeholderCount }, (_unused, index) => (
|
||||||
|
<span
|
||||||
|
key={`reserved-action-slot-${index}`}
|
||||||
|
className="table-action-placeholder"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,15 @@ export default function SettingsPage({
|
|||||||
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
||||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||||
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? 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 canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
||||||
const contributedSections = useMemo(
|
const contributedSections = useMemo(
|
||||||
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
||||||
@@ -319,8 +327,8 @@ export default function SettingsPage({
|
|||||||
scopeId={auth.user.id}
|
scopeId={auth.user.id}
|
||||||
profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798"
|
profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798"
|
||||||
policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23"
|
policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23"
|
||||||
canWriteProfiles={hasScope(auth, "mail_servers:write")}
|
canWriteProfiles={hasAnyScope(auth, ["mail_servers:write", "mail:profile:write_own"])}
|
||||||
canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")}
|
canManageCredentials={hasAnyScope(auth, ["mail_servers:manage_credentials", "mail:secret:manage_own"])}
|
||||||
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />
|
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -686,6 +686,15 @@
|
|||||||
place-items: center;
|
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 {
|
.data-grid-empty-message {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
|
|||||||
@@ -32,18 +32,23 @@ const emptyAction = renderToStaticMarkup(
|
|||||||
);
|
);
|
||||||
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
|
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
|
||||||
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
|
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(
|
const contextActions = renderToStaticMarkup(
|
||||||
<TableActionGroup
|
<TableActionGroup
|
||||||
actions={[
|
actions={[
|
||||||
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop },
|
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop },
|
||||||
{ id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, 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(buttonCount(contextActions), 3, "row-level unavailable actions remain visible");
|
||||||
assertEqual(nonSubmittingButtonCount(contextActions), 2, "table action groups do not submit an enclosing form");
|
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('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('title="Inspect"'), true, "table actions expose a native tooltip");
|
||||||
assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative");
|
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");
|
assertEqual(actionCalled, true, "table actions still invoke their handler");
|
||||||
|
|
||||||
const noReorderActions = renderToStaticMarkup(
|
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");
|
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>);
|
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=\"\""), true, "a disabled reason disables the central button");
|
||||||
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
|
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
|
||||||
|
|||||||
Reference in New Issue
Block a user