perf(campaign): search share targets server-side
This commit is contained in:
@@ -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[] = [];
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user