Files
Microsoft.SelfService.Porta…/src/pages/DeploymentGroupsPage.tsx
Torsten Brendgen 1894f740b0 feat: add multi-select functionality across various pages
- Implemented checkbox selection for multiple items in DeploymentRulesPage, DomainsPage, EnvironmentsPage, ServicesPage, TargetsPage, TemplateCategoriesPage, and TemplatesPage.
- Added delete functionality for selected items with confirmation prompts.
- Updated state management to handle selected IDs for batch operations.
- Enhanced UI with a toolbar for actions related to selected items.
- Adjusted API calls to accommodate changes in data structure, including deployment requests and job details.
2026-07-09 23:49:10 +02:00

534 lines
22 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Checkbox,
Combobox,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
makeStyles,
Option,
shorthands,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Text,
tokens,
Tooltip,
} from "@fluentui/react-components";
import { AddRegular, DeleteRegular, OpenRegular } from "@fluentui/react-icons";
import { ChevronDownRegular, ChevronRightRegular } from "@fluentui/react-icons";
import { Fragment, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader";
import type { DeploymentBatch } from "../types/portal";
const statusOrder = ["New", "Pending", "Running", "Failed", "Completed", "Unknown"];
const useStyles = makeStyles({
pageActions: {
display: "flex",
gap: "10px",
marginBottom: "16px",
},
groupRow: {
cursor: "pointer",
},
groupHeader: {
display: "flex",
alignItems: "center",
gap: "8px",
},
nestedLevel2Cell: {
paddingLeft: "48px",
},
actions: {
display: "flex",
gap: "4px",
...shorthands.padding("2px", "0"),
},
muted: {
color: tokens.colorNeutralForeground3,
},
idText: {
color: tokens.colorNeutralForeground3,
fontFamily: "Consolas, monospace",
fontSize: "12px",
},
emptyState: {
backgroundColor: tokens.colorNeutralBackground1,
color: tokens.colorNeutralForeground3,
...shorthands.border("1px", "solid", tokens.colorNeutralStroke2),
...shorthands.borderRadius(tokens.borderRadiusMedium),
...shorthands.padding("24px"),
},
dialogIntro: {
color: tokens.colorNeutralForeground3,
marginBottom: "16px",
},
targetPicker: {
display: "grid",
gap: "10px",
},
});
export function DeploymentGroupsPage() {
const styles = useStyles();
const queryClient = useQueryClient();
const [serviceId, setServiceId] = useState("");
const [templateId, setTemplateId] = useState("");
const [templateVersionId, setTemplateVersionId] = useState("");
const [deploymentRuleId, setDeploymentRuleId] = useState("");
const [targetIds, setTargetIds] = useState<string[]>([]);
const [targetSearch, setTargetSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteCandidate, setDeleteCandidate] = useState<DeploymentBatch | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [expandedStatusGroups, setExpandedStatusGroups] = useState<Record<string, boolean>>({});
const { data, error, isLoading } = useQuery({
queryKey: ["deployment-batches"],
queryFn: ({ signal }) => portalApi.getDeploymentBatches(signal),
});
const { data: services } = useQuery({
queryKey: ["services"],
queryFn: ({ signal }) => portalApi.getServices(signal),
});
const { data: templates } = useQuery({
queryKey: ["templates"],
queryFn: ({ signal }) => portalApi.getTemplates(signal),
});
const { data: templateCategories } = useQuery({
queryKey: ["template-categories"],
queryFn: ({ signal }) => portalApi.getTemplateCategories(signal),
});
const { data: targets } = useQuery({
queryKey: ["targets"],
queryFn: ({ signal }) => portalApi.getTargets(signal),
});
const { data: templateVersions } = useQuery({
queryKey: ["template-versions", templateId],
queryFn: ({ signal }) => portalApi.getTemplateVersions(templateId, signal),
enabled: Boolean(templateId),
});
const { data: deploymentRules } = useQuery({
queryKey: ["deployment-rules"],
queryFn: ({ signal }) => portalApi.getDeploymentRules(signal),
});
const addDeploymentBatch = useMutation({
mutationFn: portalApi.addDeploymentBatch,
onSuccess: async () => {
setServiceId("");
setTemplateId("");
setTemplateVersionId("");
setDeploymentRuleId("");
setTargetIds([]);
setTargetSearch("");
setDialogOpen(false);
await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["deployments"] });
},
});
const deleteDeploymentBatch = useMutation({
mutationFn: portalApi.deleteDeploymentBatch,
onSuccess: async () => {
setDeleteCandidate(null);
await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["deployments"] });
},
});
const deleteSelectedDeploymentBatches = useMutation({
mutationFn: async (ids: string[]) => {
await Promise.all(ids.map((id) => portalApi.deleteDeploymentBatch(id)));
},
onSuccess: async () => {
setSelectedIds([]);
await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["deployments"] });
},
});
const selectedService = (services ?? []).find((service) => service.id === serviceId);
const isOnPremService = selectedService ? !selectedService.isCloudService : false;
const filteredTargets = useMemo(() => {
const search = targetSearch.trim().toLowerCase();
if (!search) {
return targets ?? [];
}
return (targets ?? []).filter((target) => {
const name = target.name?.toLowerCase() ?? "";
const id = target.id?.toLowerCase() ?? "";
return name.includes(search) || id.includes(search);
});
}, [targetSearch, targets]);
const allVisibleSelected =
filteredTargets.length > 0 &&
filteredTargets.every((target) => targetIds.includes(target.id));
const groupedBatches = useMemo(() => {
const groups = (data ?? []).reduce<Record<string, DeploymentBatch[]>>((acc, deploymentBatch) => {
const status = deploymentBatch.status || "Unknown";
acc[status] = [...(acc[status] ?? []), deploymentBatch];
return acc;
}, {});
return Object.entries(groups).sort(([left], [right]) => {
const leftIndex = statusOrder.indexOf(left);
const rightIndex = statusOrder.indexOf(right);
return (leftIndex === -1 ? statusOrder.length : leftIndex) - (rightIndex === -1 ? statusOrder.length : rightIndex);
});
}, [data]);
const allSelected = Boolean(data?.length) && data!.every((deploymentBatch) => selectedIds.includes(deploymentBatch.id));
const toggleSelected = (id: string, checked: boolean) => {
setSelectedIds((current) => (checked ? [...new Set([...current, id])] : current.filter((entry) => entry !== id)));
};
useEffect(() => {
if (!templateVersions || templateVersions.length === 0 || templateVersionId) {
return;
}
const publishedVersion = templateVersions.find((version) => version.isPublished);
setTemplateVersionId((publishedVersion ?? templateVersions[0]).id);
}, [templateVersionId, templateVersions]);
const getServiceName = (deploymentBatch: DeploymentBatch) => {
const template = (templates ?? []).find((entry) => entry.id === deploymentBatch.templateId);
const category = (templateCategories ?? []).find((entry) => entry.id === template?.templateCategoryId);
const service = (services ?? []).find((entry) => entry.id === category?.serviceId);
return service?.name ?? "-";
};
const getPrimaryTemplateLabel = (deploymentBatch: DeploymentBatch) => {
const fallbackTemplate = (templates ?? []).find((entry) => entry.id === deploymentBatch.templateId);
const templateName = deploymentBatch.primaryTemplateName ?? fallbackTemplate?.name ?? "-";
const templateVersion = deploymentBatch.primaryTemplateVersion ? ` ${deploymentBatch.primaryTemplateVersion}` : "";
const additionalTemplates =
deploymentBatch.templateSelectionCount > 1 ? ` +${deploymentBatch.templateSelectionCount - 1}` : "";
return `${templateName}${templateVersion}${additionalTemplates}`;
};
const toggleStatusGroup = (status: string) => {
setExpandedStatusGroups((current) => ({
...current,
[status]: !current[status],
}));
};
return (
<>
<PageHeader title="Deployments" description="Deployment Batches und Compositionen nach Status." />
<div className={styles.pageActions}>
<Button appearance="primary" icon={<AddRegular />} onClick={() => setDialogOpen(true)}>
Neuer Deployment Batch
</Button>
<Button
appearance="secondary"
disabled={selectedIds.length === 0 || deleteSelectedDeploymentBatches.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`${selectedIds.length} Deployment Batches wirklich loeschen?`)) {
deleteSelectedDeploymentBatches.mutate(selectedIds);
}
}}
>
Auswahl loeschen
</Button>
</div>
<DataState isLoading={isLoading} error={error ?? deleteSelectedDeploymentBatches.error} />
{!isLoading && !error && groupedBatches.length === 0 && (
<div className={styles.emptyState}>Keine Deployment Batches vorhanden.</div>
)}
{!isLoading && !error && groupedBatches.length > 0 && (
<Table aria-label="Deployment batches">
<TableHeader>
<TableRow>
<TableHeaderCell>
<Checkbox
checked={allSelected}
onChange={(_, checkboxData) =>
setSelectedIds(checkboxData.checked ? (data ?? []).map((deploymentBatch) => deploymentBatch.id) : [])
}
/>
</TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Service</Text>
</TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Primary Template</Text>
</TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Templates</Text>
</TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Targets</Text>
</TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Rule</Text>
</TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Created</Text>
</TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Batch</Text>
</TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Actions</Text>
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{groupedBatches.map(([status, deploymentBatches]) => (
<Fragment key={`status-${status}`}>
<TableRow className={styles.groupRow} onClick={() => toggleStatusGroup(status)}>
<TableCell colSpan={9}>
<div className={styles.groupHeader}>
{expandedStatusGroups[status] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
<strong>{status}</strong>
</div>
</TableCell>
</TableRow>
{expandedStatusGroups[status] !== false &&
deploymentBatches.map((deploymentBatch) => (
<TableRow key={deploymentBatch.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(deploymentBatch.id)}
onChange={(_, checkboxData) => toggleSelected(deploymentBatch.id, Boolean(checkboxData.checked))}
/>
</TableCell>
<TableCell className={styles.nestedLevel2Cell}>{getServiceName(deploymentBatch)}</TableCell>
<TableCell>{getPrimaryTemplateLabel(deploymentBatch)}</TableCell>
<TableCell>{deploymentBatch.templateSelectionCount ?? 0}</TableCell>
<TableCell>{deploymentBatch.targetAssignmentCount ?? 0}</TableCell>
<TableCell>
{(deploymentRules ?? []).find((entry) => entry.id === deploymentBatch.deploymentRuleId)?.name ?? "-"}
</TableCell>
<TableCell>{deploymentBatch.created ? new Date(deploymentBatch.created).toLocaleString() : "-"}</TableCell>
<TableCell>
<Tooltip content={deploymentBatch.id} relationship="label">
<span className={styles.idText}>{deploymentBatch.id.slice(0, 8)}</span>
</Tooltip>
</TableCell>
<TableCell>
<div className={styles.actions}>
<Tooltip content="Details" relationship="label">
<Link to={`/deployments/${deploymentBatch.id}`}>
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} />
</Link>
</Tooltip>
<Tooltip content="Delete" relationship="label">
<Button
appearance="subtle"
aria-label="Delete"
icon={<DeleteRegular />}
onClick={() => setDeleteCandidate(deploymentBatch)}
/>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</Fragment>
))}
</TableBody>
</Table>
)}
<Dialog open={dialogOpen} onOpenChange={(_, data) => setDialogOpen(data.open)}>
<DialogSurface>
<DialogBody>
<DialogTitle>Neuer Deployment Batch</DialogTitle>
<DialogContent>
<Text className={styles.dialogIntro}>
Erstellt eine neue Deployment Composition mit einer initialen Template Selection.
</Text>
<Field label="Service" required>
<Combobox
placeholder="Service waehlen"
value={services?.find((service) => service.id === serviceId)?.name ?? ""}
onOptionSelect={(_, data) => {
const nextServiceId = data.optionValue ?? "";
setServiceId(nextServiceId);
setTemplateId("");
setTemplateVersionId("");
setTargetIds([]);
}}
>
{(services ?? []).map((service) => (
<Option key={service.id} text={service.name} value={service.id}>
{service.name}
</Option>
))}
</Combobox>
</Field>
<Field label="Initial Template" required>
<Combobox
placeholder={serviceId ? "Template waehlen" : "Erst Service waehlen"}
disabled={!serviceId}
value={templates?.find((template) => template.id === templateId)?.name ?? ""}
onOptionSelect={(_, data) => {
setTemplateId(data.optionValue ?? "");
setTemplateVersionId("");
}}
>
{(templates ?? [])
.filter((template) => {
const category = (templateCategories ?? []).find((entry) => entry.id === template.templateCategoryId);
return category?.serviceId === serviceId;
})
.map((template) => (
<Option key={template.id} text={template.name} value={template.id}>
{template.name}
</Option>
))}
</Combobox>
</Field>
<Field label="Version" required>
<Combobox
placeholder={templateId ? "Version waehlen" : "Erst Template waehlen"}
disabled={!templateId}
value={templateVersions?.find((version) => version.id === templateVersionId)?.version ?? ""}
onOptionSelect={(_, data) => setTemplateVersionId(data.optionValue ?? "")}
>
{(templateVersions ?? []).map((version) => (
<Option key={version.id} text={version.version} value={version.id}>
{version.version}
{version.isPublished ? " published" : ""}
</Option>
))}
</Combobox>
</Field>
<Field label="Deployment Rule">
<Combobox
placeholder="Rule waehlen (optional)"
value={deploymentRules?.find((rule) => rule.id === deploymentRuleId)?.name ?? ""}
onOptionSelect={(_, data) => setDeploymentRuleId(data.optionValue ?? "")}
>
{(deploymentRules ?? []).map((rule) => (
<Option key={rule.id} text={rule.name} value={rule.id}>
{rule.name}
</Option>
))}
</Combobox>
</Field>
{isOnPremService && (
<Field label="Initial Targets" required>
<div className={styles.targetPicker}>
<Input
placeholder="Target suchen (Name oder Id)"
value={targetSearch}
onChange={(_, data) => setTargetSearch(data.value)}
/>
<Table aria-label="Targets selection">
<TableHeader>
<TableRow>
<TableHeaderCell>
<Checkbox
checked={allVisibleSelected}
onChange={(_, data) => {
if (data.checked) {
setTargetIds((prev) =>
Array.from(new Set([...prev, ...filteredTargets.map((target) => target.id)])),
);
} else {
const visibleIds = new Set(filteredTargets.map((target) => target.id));
setTargetIds((prev) => prev.filter((id) => !visibleIds.has(id)));
}
}}
/>
</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Provider</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredTargets.map((target) => (
<TableRow key={target.id}>
<TableCell>
<Checkbox
checked={targetIds.includes(target.id)}
onChange={(_, data) => {
if (data.checked) {
setTargetIds((prev) => [...prev, target.id]);
} else {
setTargetIds((prev) => prev.filter((entry) => entry !== target.id));
}
}}
/>
</TableCell>
<TableCell>{target.name}</TableCell>
<TableCell>{target.providerType}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</Field>
)}
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={() => setDialogOpen(false)}>
Abbrechen
</Button>
<Button
appearance="primary"
disabled={!templateId || !templateVersionId || (isOnPremService && targetIds.length === 0) || addDeploymentBatch.isPending}
onClick={() =>
addDeploymentBatch.mutate({
status: "New",
templateId,
templateVersionId,
deploymentRuleId: deploymentRuleId || undefined,
targetIds: isOnPremService ? targetIds : undefined,
})
}
>
Batch anlegen
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
<Dialog open={Boolean(deleteCandidate)} onOpenChange={(_, data) => !data.open && setDeleteCandidate(null)}>
<DialogSurface>
<DialogBody>
<DialogTitle>Deployment Batch loeschen?</DialogTitle>
<DialogContent>
<Text>
Der Batch {deleteCandidate?.id.slice(0, 8)} und seine Composition werden geloescht.
</Text>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={() => setDeleteCandidate(null)}>
Abbrechen
</Button>
<Button
appearance="primary"
disabled={!deleteCandidate || deleteDeploymentBatch.isPending}
icon={<DeleteRegular />}
onClick={() => deleteCandidate && deleteDeploymentBatch.mutate(deleteCandidate.id)}
>
Loeschen
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
</>
);
}