feat(webui): standardize discard and table actions
This commit is contained in:
@@ -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