291 lines
7.6 KiB
TypeScript
291 lines
7.6 KiB
TypeScript
"use client";
|
|
|
|
import Spinner from "@/app/components/Spinner/Spinner";
|
|
import { DataRowBase } from "@/app/features/DataTable/types";
|
|
import {
|
|
setError as setAdvancedSearchError,
|
|
setStatus,
|
|
} from "@/app/redux/advanedSearch/advancedSearchSlice";
|
|
import {
|
|
selectError,
|
|
selectFilters,
|
|
selectPagination,
|
|
selectSort,
|
|
selectStatus,
|
|
} from "@/app/redux/advanedSearch/selectors";
|
|
import { AppDispatch } from "@/app/redux/store";
|
|
import {
|
|
Alert,
|
|
Box,
|
|
Chip,
|
|
Divider,
|
|
List,
|
|
ListItem,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useDispatch, useSelector } from "react-redux";
|
|
|
|
type ResourceRow = DataRowBase & Record<string, unknown>;
|
|
|
|
type FilterValue =
|
|
| string
|
|
| {
|
|
operator?: string;
|
|
value: string;
|
|
};
|
|
|
|
interface AdminResourceListProps {
|
|
title: string;
|
|
endpoint: string;
|
|
responseCollectionKeys?: string[];
|
|
primaryLabelKeys: string[];
|
|
chipKeys?: string[];
|
|
excludeKeys?: string[];
|
|
filterOverrides?: Record<string, FilterValue>;
|
|
}
|
|
|
|
const DEFAULT_COLLECTION_KEYS = ["data", "items"];
|
|
|
|
const ensureRowId = (
|
|
row: Record<string, unknown>,
|
|
fallbackId: number
|
|
): ResourceRow => {
|
|
const currentId = row.id;
|
|
|
|
if (typeof currentId === "number") {
|
|
return row as ResourceRow;
|
|
}
|
|
|
|
const numericId = Number(currentId);
|
|
|
|
if (!Number.isNaN(numericId) && numericId !== 0) {
|
|
return { ...row, id: numericId } as ResourceRow;
|
|
}
|
|
|
|
return { ...row, id: fallbackId } as ResourceRow;
|
|
};
|
|
|
|
const resolveCollection = (
|
|
payload: Record<string, unknown>,
|
|
preferredKeys: string[] = []
|
|
) => {
|
|
for (const key of [...preferredKeys, ...DEFAULT_COLLECTION_KEYS]) {
|
|
const maybeCollection = payload?.[key];
|
|
if (Array.isArray(maybeCollection)) {
|
|
return maybeCollection as Record<string, unknown>[];
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(payload)) {
|
|
return payload as Record<string, unknown>[];
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
const AdminResourceList = ({
|
|
title,
|
|
endpoint,
|
|
responseCollectionKeys = [],
|
|
primaryLabelKeys,
|
|
chipKeys = [],
|
|
excludeKeys = [],
|
|
}: AdminResourceListProps) => {
|
|
const dispatch = useDispatch<AppDispatch>();
|
|
const filters = useSelector(selectFilters);
|
|
const pagination = useSelector(selectPagination);
|
|
const sort = useSelector(selectSort);
|
|
const status = useSelector(selectStatus);
|
|
const errorMessage = useSelector(selectError);
|
|
|
|
const [rows, setRows] = useState<ResourceRow[]>([]);
|
|
|
|
const normalizedTitle = title.toLowerCase();
|
|
|
|
const excludedKeys = useMemo(() => {
|
|
const baseExcluded = new Set(["id", ...primaryLabelKeys, ...chipKeys]);
|
|
excludeKeys.forEach(key => baseExcluded.add(key));
|
|
return Array.from(baseExcluded);
|
|
}, [primaryLabelKeys, chipKeys, excludeKeys]);
|
|
|
|
const getPrimaryLabel = (row: ResourceRow) => {
|
|
for (const key of primaryLabelKeys) {
|
|
if (row[key]) {
|
|
return String(row[key]);
|
|
}
|
|
}
|
|
return `${title} #${row.id}`;
|
|
};
|
|
|
|
const getMetaChips = (row: ResourceRow) =>
|
|
chipKeys
|
|
.filter(key => row[key])
|
|
.map(key => ({
|
|
key,
|
|
value: String(row[key]),
|
|
}));
|
|
|
|
const getSecondaryDetails = (row: ResourceRow) =>
|
|
Object.entries(row).filter(([key]) => !excludedKeys.includes(key));
|
|
|
|
const resolvedCollectionKeys = useMemo(
|
|
() => [...responseCollectionKeys],
|
|
[responseCollectionKeys]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const fetchResources = async () => {
|
|
dispatch(setStatus("loading"));
|
|
dispatch(setAdvancedSearchError(null));
|
|
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
filters,
|
|
pagination,
|
|
sort,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
dispatch(
|
|
setAdvancedSearchError(`Failed to fetch ${normalizedTitle}`)
|
|
);
|
|
setRows([]);
|
|
return;
|
|
}
|
|
|
|
const backendData = await response.json();
|
|
const collection = resolveCollection(
|
|
backendData,
|
|
resolvedCollectionKeys
|
|
);
|
|
|
|
const nextRows = collection.map((item, index) =>
|
|
ensureRowId(item, index + 1)
|
|
);
|
|
|
|
setRows(nextRows);
|
|
dispatch(setStatus("succeeded"));
|
|
} catch (error) {
|
|
dispatch(
|
|
setAdvancedSearchError(
|
|
error instanceof Error ? error.message : "Unknown error"
|
|
)
|
|
);
|
|
setRows([]);
|
|
}
|
|
};
|
|
|
|
fetchResources();
|
|
}, [
|
|
dispatch,
|
|
endpoint,
|
|
filters,
|
|
pagination,
|
|
sort,
|
|
resolvedCollectionKeys,
|
|
normalizedTitle,
|
|
]);
|
|
|
|
return (
|
|
<Box sx={{ p: 3, width: "100%", maxWidth: 900 }}>
|
|
<Typography variant="h5" sx={{ mb: 2 }}>
|
|
{title}
|
|
</Typography>
|
|
|
|
{status === "loading" && (
|
|
<Box sx={{ display: "flex", gap: 1, alignItems: "center", mb: 2 }}>
|
|
<Spinner size="small" color="#000" />
|
|
<Typography variant="body2">
|
|
{`Loading ${normalizedTitle}...`}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
{status === "failed" && (
|
|
<Alert severity="error" sx={{ mb: 2 }}>
|
|
{errorMessage || `Failed to load ${normalizedTitle}`}
|
|
</Alert>
|
|
)}
|
|
|
|
{!rows.length && status === "succeeded" && (
|
|
<Typography variant="body2" color="text.secondary">
|
|
{`No ${normalizedTitle} found.`}
|
|
</Typography>
|
|
)}
|
|
|
|
{rows.length > 0 && (
|
|
<List
|
|
sx={{ bgcolor: "background.paper", borderRadius: 2, boxShadow: 1 }}
|
|
>
|
|
{rows.map(row => {
|
|
const chips = getMetaChips(row);
|
|
const secondary = getSecondaryDetails(row);
|
|
|
|
return (
|
|
<Box key={row.id}>
|
|
<ListItem alignItems="flex-start">
|
|
<Box sx={{ width: "100%" }}>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
flexWrap: "wrap",
|
|
gap: 1,
|
|
}}
|
|
>
|
|
<Typography variant="subtitle1" fontWeight={600}>
|
|
{getPrimaryLabel(row)}
|
|
</Typography>
|
|
|
|
<Typography variant="caption" color="text.secondary">
|
|
ID: {row.id}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{chips.length > 0 && (
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
gap: 1,
|
|
flexWrap: "wrap",
|
|
my: 1,
|
|
}}
|
|
>
|
|
{chips.map(chip => (
|
|
<Chip
|
|
key={`${row.id}-${chip.key}`}
|
|
label={`${chip.key}: ${chip.value}`}
|
|
size="small"
|
|
color="primary"
|
|
variant="outlined"
|
|
/>
|
|
))}
|
|
</Box>
|
|
)}
|
|
|
|
{secondary.length > 0 && (
|
|
<Typography variant="body2" color="text.secondary">
|
|
{secondary
|
|
.map(([key, value]) => `${key}: ${String(value)}`)
|
|
.join(" • ")}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
</ListItem>
|
|
<Divider component="li" />
|
|
</Box>
|
|
);
|
|
})}
|
|
</List>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default AdminResourceList;
|