perf(campaign): search share targets server-side

This commit is contained in:
2026-07-30 01:30:13 +02:00
parent 46df12c025
commit c769be39da
5 changed files with 285 additions and 22 deletions

View File

@@ -1,5 +1,9 @@
import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
import { apiDownload, apiFetch } from "./client";
import {
apiReferenceOptionProvider,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery";
export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
export type {
@@ -1125,6 +1129,18 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
}
export function campaignShareTargetProvider(
settings: ApiSettings,
campaignId: string,
targetType: "user" | "group"
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
`/api/v1/campaigns/${campaignId}/share-target-options`,
{ target_type: targetType }
);
}
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
const pageSize = 500;
const shares: CampaignShare[] = [];

View File

@@ -4,15 +4,20 @@ import { KeyRound, Trash2 } from "lucide-react";
import {
fetchResourceAccessExplanation,
getCampaignShares,
getCampaignShareTargets,
campaignShareTargetProvider,
revokeCampaignShare,
updateCampaignOwner,
upsertCampaignShare,
type CampaignShare,
type CampaignShareTargets,
type ResourceAccessExplanationResponse } from
"../../../api/campaigns";
import { Button, ResourceAccessExplanation } from "@govoplan/core-webui";
import {
Button,
ReferenceSelect,
ResourceAccessExplanation,
type ReferenceOption,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
@@ -34,7 +39,10 @@ export default function CampaignAccessCard({
}: {settings: ApiSettings;auth: AuthInfo;campaign: CampaignListItem;onChanged: () => Promise<void>;onError: (message: string) => void;}) {
const [available, setAvailable] = useState<boolean | null>(null);
const [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] });
const [targets, setTargets] = useState<{
users: readonly ReferenceOption[];
groups: readonly ReferenceOption[];
}>({ users: [], groups: [] });
const [shares, setShares] = useState<CampaignShare[]>([]);
const [ownerOpen, setOwnerOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
@@ -57,6 +65,14 @@ export default function CampaignAccessCard({
hasScope(auth, "admin:roles:read") ||
hasScope(auth, "access:membership:read") ||
hasScope(auth, "access:role:read");
const userTargetProvider = useMemo(
() => campaignShareTargetProvider(settings, campaign.id, "user"),
[campaign.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]
);
const groupTargetProvider = useMemo(
() => campaignShareTargetProvider(settings, campaign.id, "group"),
[campaign.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]
);
useUnsavedDraftGuard({
dirty: ownerDirty || shareDirty,
@@ -69,12 +85,25 @@ export default function CampaignAccessCard({
async function load() {
try {
const [nextTargets, nextShares] = await Promise.all([
getCampaignShareTargets(settings, campaign.id),
getCampaignShares(settings, campaign.id)]
const nextShares = (await getCampaignShares(settings, campaign.id))
.filter((item) => !item.revoked_at);
const userIds = uniqueTargetIds(
nextShares,
"user",
campaign.owner_user_id
);
setTargets(nextTargets);
setShares(nextShares.filter((item) => !item.revoked_at));
const groupIds = uniqueTargetIds(
nextShares,
"group",
campaign.owner_group_id
);
const controller = new AbortController();
const [users, groups] = await Promise.all([
resolveReferenceOptions(userTargetProvider, userIds, controller.signal),
resolveReferenceOptions(groupTargetProvider, groupIds, controller.signal)
]);
setTargets({ users, groups });
setShares(nextShares);
setAvailable(true);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -87,18 +116,27 @@ export default function CampaignAccessCard({
setOwnerType(campaign.owner_group_id ? "group" : "user");
setOwnerId(campaign.owner_group_id || campaign.owner_user_id || "");
void load();
}, [campaign.id, campaign.owner_group_id, campaign.owner_user_id, settings.accessToken, settings.apiBaseUrl]);
}, [
campaign.id,
campaign.owner_group_id,
campaign.owner_user_id,
groupTargetProvider,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey,
userTargetProvider
]);
const targetOptions = ownerType === "user" ? targets.users : targets.groups;
const shareTargetOptions = shareType === "user" ? targets.users : targets.groups;
const ownerTargetProvider = ownerType === "user" ? userTargetProvider : groupTargetProvider;
const shareTargetProvider = shareType === "user" ? userTargetProvider : groupTargetProvider;
const targetMap = useMemo(() => new Map([
...targets.users.map((item) => [`user:${item.id}`, item] as const),
...targets.groups.map((item) => [`group:${item.id}`, item] as const)]
...targets.users.map((item) => [`user:${item.value}`, item] as const),
...targets.groups.map((item) => [`group:${item.value}`, item] as const)]
), [targets]);
const ownerLabel = campaign.owner_group_id ?
targetMap.get(`group:${campaign.owner_group_id}`)?.name || "i18n:govoplan-campaign.group_owner.55630670" :
targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "i18n:govoplan-campaign.user_owner.86e3faab";
targetMap.get(`group:${campaign.owner_group_id}`)?.label || "i18n:govoplan-campaign.group_owner.55630670" :
targetMap.get(`user:${campaign.owner_user_id || ""}`)?.label || "i18n:govoplan-campaign.user_owner.86e3faab";
function closeOwnerDialog() {
setOwnerOpen(false);
@@ -189,10 +227,10 @@ export default function CampaignAccessCard({
sticky: "start",
sortable: true,
filterable: true,
value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.name || row.target_id,
value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.label || row.target_id,
render: (row) => {
const target = targetMap.get(`${row.target_type}:${row.target_id}`);
return <div><strong>{target?.name || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: target.secondary }) : ""}</div></div>;
return <div><strong>{target?.label || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.description ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: target.description }) : ""}</div></div>;
}
},
{ id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> },
@@ -210,14 +248,32 @@ export default function CampaignAccessCard({
</Card>
<Dialog open={ownerOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.change_campaign_owner.63f80aef" onClose={() => !busy && closeOwnerDialog()} footer={<><Button onClick={closeOwnerDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>i18n:govoplan-campaign.save_owner.b6763847</Button></>}>
<FormField label="i18n:govoplan-campaign.owner_type.6b86eacc"><select value={ownerType} onChange={(event) => {const next = event.target.value as TargetType;setOwnerType(next);setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
<FormField label="i18n:govoplan-campaign.owner.89ff3122"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField>
<FormField label="i18n:govoplan-campaign.owner_type.6b86eacc"><select value={ownerType} onChange={(event) => {const next = event.target.value as TargetType;setOwnerType(next);setOwnerId("");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
<FormField label="i18n:govoplan-campaign.owner.89ff3122">
<ReferenceSelect
value={ownerId}
onChange={(value) => setOwnerId(value)}
provider={ownerTargetProvider}
selectedOption={targetMap.get(`${ownerType}:${ownerId}`) ?? null}
disabled={busy}
placeholder="i18n:govoplan-campaign.select.349ac8fb"
/>
</FormField>
<p className="muted small-note">Changing the owner clears a selected reusable mail profile from the editable current version and requires profile reselection plus validation before live delivery.</p>
</Dialog>
<Dialog open={shareOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.share_campaign.b605982b" onClose={() => !busy && closeShareDialog()} footer={<><Button onClick={closeShareDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>i18n:govoplan-campaign.save_share.bcf6ed94</Button></>}>
<FormField label="i18n:govoplan-campaign.target_type.a45f8055"><select value={shareType} onChange={(event) => {const next = event.target.value as TargetType;setShareType(next);setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
<FormField label="i18n:govoplan-campaign.user_or_group.53406ef0"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">i18n:govoplan-campaign.select.349ac8fb</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField>
<FormField label="i18n:govoplan-campaign.target_type.a45f8055"><select value={shareType} onChange={(event) => {const next = event.target.value as TargetType;setShareType(next);setShareTargetId("");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
<FormField label="i18n:govoplan-campaign.user_or_group.53406ef0">
<ReferenceSelect
value={shareTargetId}
onChange={(value) => setShareTargetId(value)}
provider={shareTargetProvider}
selectedOption={targetMap.get(`${shareType}:${shareTargetId}`) ?? null}
disabled={busy}
placeholder="i18n:govoplan-campaign.select.349ac8fb"
/>
</FormField>
<FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField>
</Dialog>
@@ -229,3 +285,36 @@ export default function CampaignAccessCard({
</>);
}
function uniqueTargetIds(
shares: readonly CampaignShare[],
targetType: TargetType,
ownerId?: string | null
): string[] {
return [...new Set([
...(ownerId ? [ownerId] : []),
...shares
.filter((share) => share.target_type === targetType)
.map((share) => share.target_id)
])];
}
async function resolveReferenceOptions(
provider: ReferenceOptionProvider,
values: readonly string[],
signal: AbortSignal
): Promise<readonly ReferenceOption[]> {
if (!values.length) return [];
if (!provider.resolve) {
return provider.search("", {
selectedValues: values,
limit: Math.min(200, Math.max(1, values.length)),
signal
});
}
return provider.resolve(values, {
selectedValues: values,
limit: Math.min(200, Math.max(1, values.length)),
signal
});
}