修复(#97): 区分邀请管理加载失败状态
This commit is contained in:
@@ -12,6 +12,8 @@
|
|||||||
"inviter": "Inviter",
|
"inviter": "Inviter",
|
||||||
"inviterGiftDays": "Inviter Gift Days",
|
"inviterGiftDays": "Inviter Gift Days",
|
||||||
"inviterId": "Inviter ID",
|
"inviterId": "Inviter ID",
|
||||||
|
"loadErrorDescription": "Refresh the table or try again later.",
|
||||||
|
"loadErrorTitle": "Failed to load invites",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
"orderCount": "Orders",
|
"orderCount": "Orders",
|
||||||
"searchPlaceholder": "Email or phone",
|
"searchPlaceholder": "Email or phone",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"inviter": "邀请人",
|
"inviter": "邀请人",
|
||||||
"inviterGiftDays": "邀请人赠送天数",
|
"inviterGiftDays": "邀请人赠送天数",
|
||||||
"inviterId": "邀请人 ID",
|
"inviterId": "邀请人 ID",
|
||||||
|
"loadErrorDescription": "刷新表格或稍后重试。",
|
||||||
|
"loadErrorTitle": "邀请记录加载失败",
|
||||||
"no": "否",
|
"no": "否",
|
||||||
"orderCount": "订单数",
|
"orderCount": "订单数",
|
||||||
"searchPlaceholder": "邮箱/手机号关键词",
|
"searchPlaceholder": "邮箱/手机号关键词",
|
||||||
|
|||||||
@@ -127,12 +127,33 @@ describe("InviteManagement", () => {
|
|||||||
expect(await screen.findByText("No invite records")).not.toBeNull();
|
expect(await screen.findByText("No invite records")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the table empty when the request fails", async () => {
|
it("shows a loading state while invites are being fetched", () => {
|
||||||
|
let resolveRequest:
|
||||||
|
| ((
|
||||||
|
value: AxiosResponse<
|
||||||
|
API.Response & { data?: API.GetAdminInviteListResponse }
|
||||||
|
>
|
||||||
|
) => void)
|
||||||
|
| undefined;
|
||||||
|
mockedGetAdminInviteList.mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveRequest = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<InviteManagement />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("status", { name: "Loading data" })).not.toBeNull();
|
||||||
|
expect(resolveRequest).toBeTypeOf("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an error state when the request fails", async () => {
|
||||||
mockedGetAdminInviteList.mockRejectedValue(new Error("network failed"));
|
mockedGetAdminInviteList.mockRejectedValue(new Error("network failed"));
|
||||||
|
|
||||||
render(<InviteManagement />);
|
render(<InviteManagement />);
|
||||||
|
|
||||||
await waitFor(() => expect(mockedGetAdminInviteList).toHaveBeenCalled());
|
await waitFor(() => expect(mockedGetAdminInviteList).toHaveBeenCalled());
|
||||||
expect(await screen.findByText("No invite records")).not.toBeNull();
|
expect(await screen.findByText("Failed to load invites")).not.toBeNull();
|
||||||
|
expect(screen.queryByText("No invite records")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from "@workspace/ui/components/alert";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
@@ -12,6 +17,7 @@ import {
|
|||||||
type ProTableActions,
|
type ProTableActions,
|
||||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||||
import { getAdminInviteList } from "@workspace/ui/services/admin/invite";
|
import { getAdminInviteList } from "@workspace/ui/services/admin/invite";
|
||||||
|
import { CircleAlert } from "lucide-react";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Display } from "@/components/display";
|
import { Display } from "@/components/display";
|
||||||
@@ -158,6 +164,17 @@ export default function InviteManagement() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
empty={<Empty description={t("empty", "No invite records")} />}
|
empty={<Empty description={t("empty", "No invite records")} />}
|
||||||
|
error={
|
||||||
|
<Alert className="mx-auto max-w-md" variant="destructive">
|
||||||
|
<CircleAlert aria-hidden="true" />
|
||||||
|
<AlertTitle>
|
||||||
|
{t("loadErrorTitle", "Failed to load invites")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t("loadErrorDescription", "Refresh the table or try again later.")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
header={{ title: t("title", "Invite Management") }}
|
header={{ title: t("title", "Invite Management") }}
|
||||||
params={[
|
params={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ import { SortableRow } from "@workspace/ui/composed/pro-table/sortable-row";
|
|||||||
import { ProTableWrapper } from "@workspace/ui/composed/pro-table/wrapper";
|
import { ProTableWrapper } from "@workspace/ui/composed/pro-table/wrapper";
|
||||||
import { cn } from "@workspace/ui/lib/utils";
|
import { cn } from "@workspace/ui/lib/utils";
|
||||||
import { useSize } from "ahooks";
|
import { useSize } from "ahooks";
|
||||||
import { GripVertical, ListRestart, Loader, RefreshCcw } from "lucide-react";
|
import {
|
||||||
|
CircleAlert,
|
||||||
|
GripVertical,
|
||||||
|
ListRestart,
|
||||||
|
Loader,
|
||||||
|
RefreshCcw,
|
||||||
|
} from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
@@ -79,6 +85,7 @@ export interface ProTableProps<TData, TValue> {
|
|||||||
selectedRowsText: (total: number) => string;
|
selectedRowsText: (total: number) => string;
|
||||||
}>;
|
}>;
|
||||||
empty?: React.ReactNode;
|
empty?: React.ReactNode;
|
||||||
|
error?: React.ReactNode;
|
||||||
onSort?: (
|
onSort?: (
|
||||||
sourceId: string | number,
|
sourceId: string | number,
|
||||||
targetId: string | number | null,
|
targetId: string | number | null,
|
||||||
@@ -104,6 +111,7 @@ export function ProTable<
|
|||||||
action,
|
action,
|
||||||
texts,
|
texts,
|
||||||
empty,
|
empty,
|
||||||
|
error,
|
||||||
onSort,
|
onSort,
|
||||||
initialFilters,
|
initialFilters,
|
||||||
}: ProTableProps<TData, TValue>) {
|
}: ProTableProps<TData, TValue>) {
|
||||||
@@ -121,6 +129,8 @@ export function ProTable<
|
|||||||
const [rowSelection, setRowSelection] = useState({});
|
const [rowSelection, setRowSelection] = useState({});
|
||||||
const [data, setData] = useState<TData[]>([]);
|
const [data, setData] = useState<TData[]>([]);
|
||||||
const [rowCount, setRowCount] = useState<number>(0);
|
const [rowCount, setRowCount] = useState<number>(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [requestError, setRequestError] = useState(false);
|
||||||
const [pagination, setPagination] = useState({
|
const [pagination, setPagination] = useState({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 200,
|
pageSize: 200,
|
||||||
@@ -193,6 +203,8 @@ export function ProTable<
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (loading.current) return;
|
if (loading.current) return;
|
||||||
loading.current = true;
|
loading.current = true;
|
||||||
|
setIsLoading(true);
|
||||||
|
setRequestError(false);
|
||||||
try {
|
try {
|
||||||
const response = await request(
|
const response = await request(
|
||||||
{
|
{
|
||||||
@@ -205,10 +217,13 @@ export function ProTable<
|
|||||||
);
|
);
|
||||||
setData(response.list);
|
setData(response.list);
|
||||||
setRowCount(response.total);
|
setRowCount(response.total);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.log("Fetch data error:", error);
|
setData([]);
|
||||||
|
setRowCount(0);
|
||||||
|
setRequestError(true);
|
||||||
} finally {
|
} finally {
|
||||||
loading.current = false;
|
loading.current = false;
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const reset = async () => {
|
const reset = async () => {
|
||||||
@@ -316,7 +331,16 @@ export function ProTable<
|
|||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel()?.rows?.length ? (
|
{requestError ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
className="py-12"
|
||||||
|
colSpan={table.getAllLeafColumns().length}
|
||||||
|
>
|
||||||
|
{error || <ProTableErrorState />}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : table.getRowModel()?.rows?.length ? (
|
||||||
onSort ? (
|
onSort ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<SortableRow
|
<SortableRow
|
||||||
@@ -371,7 +395,10 @@ export function ProTable<
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="py-24" colSpan={columns.length + 2}>
|
<TableCell
|
||||||
|
className="py-24"
|
||||||
|
colSpan={table.getAllLeafColumns().length}
|
||||||
|
>
|
||||||
{empty || <Empty />}
|
{empty || <Empty />}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -380,8 +407,12 @@ export function ProTable<
|
|||||||
</Table>
|
</Table>
|
||||||
</ProTableWrapper>
|
</ProTableWrapper>
|
||||||
|
|
||||||
{loading.current && (
|
{isLoading && (
|
||||||
<div className="absolute top-0 z-20 flex h-full w-full items-center justify-center bg-muted/80">
|
<div
|
||||||
|
aria-label="Loading data"
|
||||||
|
className="absolute top-0 z-20 flex h-full w-full items-center justify-center bg-muted/80"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
<Loader className="h-4 w-4 animate-spin" />
|
<Loader className="h-4 w-4 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -391,6 +422,16 @@ export function ProTable<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProTableErrorState() {
|
||||||
|
return (
|
||||||
|
<Alert className="mx-auto max-w-md" variant="destructive">
|
||||||
|
<CircleAlert aria-hidden="true" />
|
||||||
|
<AlertTitle>Failed to load data</AlertTitle>
|
||||||
|
<AlertDescription>Refresh the table or try again later.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function createSelectColumn<TData, TValue>(): ColumnDef<TData, TValue> {
|
function createSelectColumn<TData, TValue>(): ColumnDef<TData, TValue> {
|
||||||
return {
|
return {
|
||||||
id: "selected",
|
id: "selected",
|
||||||
|
|||||||
Reference in New Issue
Block a user