Initialize configurable Quick Access module
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
import { ArrowDown, ArrowUp, RotateCcw, Save } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
DismissibleAlert,
|
||||
IconButton,
|
||||
i18nMessage,
|
||||
LoadingFrame,
|
||||
SegmentedControl,
|
||||
ToggleSwitch,
|
||||
usePlatformLanguage,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
loadEffectiveQuickAccess,
|
||||
loadQuickAccessCatalogue,
|
||||
loadQuickAccessProfile,
|
||||
saveQuickAccessProfile,
|
||||
type EffectiveQuickAccess,
|
||||
type QuickAccessCatalogue,
|
||||
type QuickAccessPreference,
|
||||
type QuickAccessProfile
|
||||
} from "../../api/quickAccess";
|
||||
|
||||
|
||||
type ProfileScope = "system" | "tenant" | "me";
|
||||
type Draft = {
|
||||
categories: Record<string, QuickAccessPreference>;
|
||||
tools: Record<string, QuickAccessPreference>;
|
||||
};
|
||||
type AvailabilityMode = "inherit" | "available" | "blocked" | "forced";
|
||||
|
||||
const EMPTY_DRAFT: Draft = { categories: {}, tools: {} };
|
||||
|
||||
export default function QuickAccessSettingsPanel({
|
||||
settings,
|
||||
auth,
|
||||
scope,
|
||||
canWrite
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
scope: ProfileScope;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [catalogue, setCatalogue] = useState<QuickAccessCatalogue | null>(null);
|
||||
const [effective, setEffective] = useState<EffectiveQuickAccess | null>(null);
|
||||
const [profile, setProfile] = useState<QuickAccessProfile | null>(null);
|
||||
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [messageTone, setMessageTone] = useState<"success" | "warning">("success");
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const isPersonal = scope === "me";
|
||||
const dirty = useMemo(
|
||||
() => profile !== null && JSON.stringify(draft) !== JSON.stringify({
|
||||
categories: profile.category_preferences,
|
||||
tools: profile.tool_preferences
|
||||
}),
|
||||
[draft, profile]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [auth.user.account_id, scope, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: reset
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
const includeAll = scope !== "me";
|
||||
try {
|
||||
const [nextCatalogue, nextProfile, nextEffective] = await Promise.all([
|
||||
loadQuickAccessCatalogue(settings, includeAll),
|
||||
loadQuickAccessProfile(settings, scope),
|
||||
loadEffectiveQuickAccess(settings, includeAll)
|
||||
]);
|
||||
setCatalogue(nextCatalogue);
|
||||
setProfile(nextProfile);
|
||||
setEffective(nextEffective);
|
||||
setDraft({
|
||||
categories: nextProfile.category_preferences,
|
||||
tools: nextProfile.tool_preferences
|
||||
});
|
||||
} catch (reason) {
|
||||
setMessageTone("warning");
|
||||
setMessage(reason instanceof Error ? reason.message : "i18n:govoplan-quick-access.load_failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
if (!profile || !canWrite) return false;
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const next = await saveQuickAccessProfile(
|
||||
settings,
|
||||
scope,
|
||||
profile,
|
||||
draft.categories,
|
||||
draft.tools
|
||||
);
|
||||
setProfile(next);
|
||||
setDraft({ categories: next.category_preferences, tools: next.tool_preferences });
|
||||
setMessageTone("success");
|
||||
setMessage("i18n:govoplan-quick-access.saved");
|
||||
window.dispatchEvent(new CustomEvent("govoplan:quick-access-changed"));
|
||||
return true;
|
||||
} catch (reason) {
|
||||
setMessageTone("warning");
|
||||
setMessage(reason instanceof Error ? reason.message : "i18n:govoplan-quick-access.save_failed");
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (!profile) return;
|
||||
setDraft({
|
||||
categories: profile.category_preferences,
|
||||
tools: profile.tool_preferences
|
||||
});
|
||||
}
|
||||
|
||||
function setPreference(
|
||||
group: "categories" | "tools",
|
||||
id: string,
|
||||
preference: QuickAccessPreference | null
|
||||
) {
|
||||
setDraft((current) => {
|
||||
const nextGroup = { ...current[group] };
|
||||
if (preference === null) delete nextGroup[id];
|
||||
else nextGroup[id] = preference;
|
||||
return { ...current, [group]: nextGroup };
|
||||
});
|
||||
}
|
||||
|
||||
function move(
|
||||
group: "categories" | "tools",
|
||||
ids: string[],
|
||||
id: string,
|
||||
direction: -1 | 1
|
||||
) {
|
||||
const currentIndex = ids.indexOf(id);
|
||||
const targetIndex = currentIndex + direction;
|
||||
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= ids.length) return;
|
||||
const reordered = [...ids];
|
||||
[reordered[currentIndex], reordered[targetIndex]] = [reordered[targetIndex], reordered[currentIndex]];
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[group]: Object.fromEntries(reordered.map((itemId, index) => [
|
||||
itemId,
|
||||
{ ...(current[group][itemId] ?? {}), order: (index + 1) * 10 }
|
||||
]))
|
||||
}));
|
||||
}
|
||||
|
||||
const categories = useMemo(
|
||||
() => [...(catalogue?.categories ?? [])].sort((left, right) =>
|
||||
(draft.categories[left.id]?.order ?? left.order) - (draft.categories[right.id]?.order ?? right.order)
|
||||
),
|
||||
[catalogue, draft.categories]
|
||||
);
|
||||
const tools = useMemo(
|
||||
() => [...(catalogue?.tools ?? [])].sort((left, right) => {
|
||||
if (left.category_id !== right.category_id) return left.category_id.localeCompare(right.category_id);
|
||||
return (draft.tools[left.id]?.order ?? left.order) - (draft.tools[right.id]?.order ?? right.order);
|
||||
}),
|
||||
[catalogue, draft.tools]
|
||||
);
|
||||
const lockedCategories = new Map(
|
||||
(effective?.categories ?? []).filter((item) => item.locked_by).map((item) => [item.id, item.locked_by])
|
||||
);
|
||||
const lockedTools = new Map(
|
||||
(effective?.categories ?? []).flatMap((category) => category.tools).filter((item) => item.locked_by).map((item) => [item.id, item.locked_by])
|
||||
);
|
||||
const categoryIds = categories.map((item) => item.id);
|
||||
|
||||
return (
|
||||
<div className="quick-access-settings">
|
||||
<div className="quick-access-settings-heading">
|
||||
<div>
|
||||
<h2>i18n:govoplan-quick-access.settings_title</h2>
|
||||
<p>{isPersonal ? "i18n:govoplan-quick-access.personal_help" : "i18n:govoplan-quick-access.admin_help"}</p>
|
||||
</div>
|
||||
<div className="quick-access-settings-actions">
|
||||
<Button variant="secondary" icon={<RotateCcw size={16} />} disabled={!dirty || saving} onClick={reset}>i18n:govoplan-quick-access.discard</Button>
|
||||
<Button variant="primary" icon={<Save size={16} />} disabled={!dirty || saving || !canWrite} onClick={() => void save()}>i18n:govoplan-quick-access.save</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message ? <DismissibleAlert tone={messageTone} resetKey={message}>{message}</DismissibleAlert> : null}
|
||||
{profile && (profile.stale_category_ids.length || profile.stale_tool_ids.length) ? (
|
||||
<DismissibleAlert tone="info" resetKey={`${profile.stale_category_ids.join(",")}:${profile.stale_tool_ids.join(",")}`}>
|
||||
i18n:govoplan-quick-access.stale_preferences_retained
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
|
||||
<section className="quick-access-settings-section">
|
||||
<h3>i18n:govoplan-quick-access.categories</h3>
|
||||
<div className="quick-access-preference-list">
|
||||
{categories.map((category, index) => (
|
||||
<PreferenceRow
|
||||
key={category.id}
|
||||
id={category.id}
|
||||
label={translateText(category.label)}
|
||||
description={translateText(category.description)}
|
||||
preference={draft.categories[category.id]}
|
||||
isPersonal={isPersonal}
|
||||
lockedBy={editableConstraintSource(scope, lockedCategories.get(category.id))}
|
||||
disabled={!canWrite || saving}
|
||||
onChange={(value) => setPreference("categories", category.id, value)}
|
||||
onMoveUp={() => move("categories", categoryIds, category.id, -1)}
|
||||
onMoveDown={() => move("categories", categoryIds, category.id, 1)}
|
||||
first={index === 0}
|
||||
last={index === categories.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="quick-access-settings-section">
|
||||
<h3>i18n:govoplan-quick-access.registered_tools</h3>
|
||||
<div className="quick-access-preference-list">
|
||||
{categories.flatMap((category) => {
|
||||
const categoryTools = tools.filter((tool) => tool.category_id === category.id);
|
||||
const toolIds = categoryTools.map((tool) => tool.id);
|
||||
return categoryTools.map((tool, index) => (
|
||||
<PreferenceRow
|
||||
key={tool.id}
|
||||
id={tool.id}
|
||||
label={translateText(tool.label)}
|
||||
description={`${translateText(category.label)} · ${tool.module_id}${tool.description ? ` · ${translateText(tool.description)}` : ""}`}
|
||||
preference={draft.tools[tool.id]}
|
||||
defaultEnabled={tool.default_enabled}
|
||||
isPersonal={isPersonal}
|
||||
lockedBy={editableConstraintSource(scope, lockedTools.get(tool.id))}
|
||||
disabled={!canWrite || saving}
|
||||
onChange={(value) => setPreference("tools", tool.id, value)}
|
||||
onMoveUp={() => move("tools", toolIds, tool.id, -1)}
|
||||
onMoveDown={() => move("tools", toolIds, tool.id, 1)}
|
||||
first={index === 0}
|
||||
last={index === categoryTools.length - 1}
|
||||
/>
|
||||
));
|
||||
})}
|
||||
{!tools.length ? <p className="quick-access-empty">i18n:govoplan-quick-access.no_registered_tools</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function PreferenceRow({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
preference,
|
||||
defaultEnabled = true,
|
||||
isPersonal,
|
||||
lockedBy,
|
||||
disabled,
|
||||
onChange,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
first,
|
||||
last
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
preference?: QuickAccessPreference;
|
||||
defaultEnabled?: boolean;
|
||||
isPersonal: boolean;
|
||||
lockedBy?: string | null;
|
||||
disabled: boolean;
|
||||
onChange: (value: QuickAccessPreference | null) => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
}) {
|
||||
const mode = preferenceMode(preference);
|
||||
const rowDisabled = disabled || Boolean(lockedBy);
|
||||
return (
|
||||
<div className={`quick-access-preference-row${lockedBy ? " is-locked" : ""}`} data-preference-id={id}>
|
||||
<div className="quick-access-preference-copy">
|
||||
<strong>{label}</strong>
|
||||
<small>{description}</small>
|
||||
{lockedBy ? (
|
||||
<small>
|
||||
{i18nMessage("i18n:govoplan-quick-access.locked_by_value", {
|
||||
value0: lockedBy
|
||||
})}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="quick-access-preference-controls">
|
||||
{isPersonal ? (
|
||||
<ToggleSwitch
|
||||
label={label}
|
||||
inactiveLabel="i18n:govoplan-quick-access.hidden"
|
||||
activeLabel="i18n:govoplan-quick-access.visible"
|
||||
checked={preference?.enabled ?? defaultEnabled}
|
||||
disabled={rowDisabled}
|
||||
onChange={(enabled) => onChange({ ...preference, enabled })}
|
||||
/>
|
||||
) : (
|
||||
<SegmentedControl
|
||||
ariaLabel={`${label} availability`}
|
||||
role="group"
|
||||
value={mode}
|
||||
disabled={rowDisabled}
|
||||
onChange={(value) => onChange(preferenceForMode(value, preference))}
|
||||
options={[
|
||||
{ id: "inherit", label: "i18n:govoplan-quick-access.inherit" },
|
||||
{ id: "available", label: "i18n:govoplan-quick-access.available" },
|
||||
{ id: "blocked", label: "i18n:govoplan-quick-access.blocked" },
|
||||
{ id: "forced", label: "i18n:govoplan-quick-access.forced" }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<div className="quick-access-order-buttons">
|
||||
<IconButton label="i18n:govoplan-quick-access.move_up" icon={<ArrowUp size={16} />} variant="ghost" disabled={disabled || first} onClick={onMoveUp} />
|
||||
<IconButton label="i18n:govoplan-quick-access.move_down" icon={<ArrowDown size={16} />} variant="ghost" disabled={disabled || last} onClick={onMoveDown} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function preferenceMode(preference?: QuickAccessPreference): AvailabilityMode {
|
||||
if (!preference || preference.enabled === null || preference.enabled === undefined) return "inherit";
|
||||
if (preference.enabled === false) return "blocked";
|
||||
return preference.forced ? "forced" : "available";
|
||||
}
|
||||
|
||||
function editableConstraintSource(
|
||||
scope: ProfileScope,
|
||||
source: string | null | undefined
|
||||
): string | null {
|
||||
if (!source || scope === "system") return null;
|
||||
if (scope === "tenant") return source === "system" ? source : null;
|
||||
return source;
|
||||
}
|
||||
|
||||
function preferenceForMode(
|
||||
mode: AvailabilityMode,
|
||||
current?: QuickAccessPreference
|
||||
): QuickAccessPreference | null {
|
||||
if (mode === "inherit") return current?.order === undefined ? null : { order: current.order };
|
||||
if (mode === "blocked") return { ...current, enabled: false, forced: false };
|
||||
if (mode === "forced") return { ...current, enabled: true, forced: true };
|
||||
return { ...current, enabled: true, forced: false };
|
||||
}
|
||||
Reference in New Issue
Block a user