feat(webui): standardize discard and table actions

This commit is contained in:
2026-07-21 20:47:54 +02:00
parent bf0729eb59
commit 41ad057f7e
7 changed files with 151 additions and 32 deletions

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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"])} />
}

View File

@@ -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;

View File

@@ -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");