Merge pull request #1166 from clusterio/implement-1165-separate-pr

Handle missing, deleted and failed resources the same way on web UI view pages
This commit is contained in:
Daniel authored and GitHub committed 2026-11-01 17:40:50 +01:00
commit 3abcba5991
18 files changed
+233 -112

No files matched your search

+29
View File
@@ -16,6 +16,7 @@ Each entrypoint of a plugin exports a function that attaches handlers to hooks c
- [Defining Link Messages](#defining-link-messages)
- [Sending Link Messages](#sending-link-messages)
- [Handling connection events](#handling-connection-events)
- [Web Pages for a Single Resource](#web-pages-for-a-single-resource)
- [Collecting Statistics](#collecting-statistics)
- [Adding Custom Commands to clusterioctl](#adding-custom-commands-to-clusterioctl)
@@ -842,6 +843,34 @@ This means that if your plugin sends a lot of events or requests, they can end u
To avoid this you should be throtteling and/or stopping your requests/events after `drop` has been raised, and continue back as normal when `resume` is raised.
## Web Pages for a Single Resource
A web page showing one resource from a subscription has more to handle than the resource itself.
It can still be loading, it might not exist, it can be deleted while the page is open, and the subscription is rejected if the user lacks the permission for it.
`useResourceState` and `ResourceState` from `@clusterio/web_ui` show these the same way as the pages for hosts, instances and the other built in resources.
```tsx
function FrobberPage() {
const id = Number(useParams().id);
const [frobbers, synced, error] = useSyncExternalStore(subscribe, () => frobberSubscriber.getSnapshot());
const frobber = frobbers.get(id);
const state = useResourceState(frobber, synced, error, id);
const nav = [{ name: "Frobbers", path: "/frobbers" }, { name: frobber?.name ?? String(id) }];
if (!frobber) {
return <ResourceState state={state} type="Frobber" id={id} nav={nav} listPath="/frobbers" error={error} />;
}
return <PageLayout nav={nav}>{frobber.name}</PageLayout>;
}
```
`useResourceState` returns `"ok"` when there is a value and otherwise `"loading"`, `"missing"`, `"deleted"` or `"error"`.
It tells deleted from missing by remembering if it has seen a value for that id.
`ResourceState` renders the whole page for the states without a value, with a link back to the page at `listPath`.
The `useHost`, `useInstance`, `useUser`, `useRole` and `useModPack` hooks return `[value, synced, error]` so they can be used the same way.
## Collecting Statistics
Clusterio comes with its own Prometheus client implementation, one part due to Not Invented Here and another part due to collectors in prom-client being difficult to get to work nicely with collecting data from plugins optionally loaded at runtime on different computers.
@@ -1,5 +1,5 @@
import React, { useContext, useState } from "react";
import { Descriptions, Spin, Typography, Button, Space, Popconfirm, Flex, Tooltip } from "antd";
import { Descriptions, Typography, Button, Space, Popconfirm, Flex, Tooltip } from "antd";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import { useNavigate, useParams } from "react-router-dom";
@@ -17,11 +17,13 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import ResourceNotes from "./ResourceNotes";
import ResourceState from "./ResourceState";
import {
MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed,
MetricDiskUsed, MetricDiskRatio, MetricRelativeDate,
} from "./system_metrics";
import { useSystems } from "../model/system";
import useResourceState from "../util/useResourceState";
import { hasNpmButtonPermission, NpmButton } from "./NpmButton";
import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton";
@@ -132,21 +134,15 @@ export default function HostViewPage() {
let [instances] = useInstances();
const [systems] = useSystems();
const system = systems.get(hostId);
const [host, synced] = useHost(hostId);
const [host, synced, error] = useHost(hostId);
const state = useResourceState(host, synced, error, hostId);
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("info");
const canShowNpm = hasNpmButtonPermission(false);
const hostInstances = new Map([...instances].filter(([_id, instance]) => instance.assignedHost === hostId));
let nav = [{ name: "Hosts", path: "/hosts" }, { name: host?.name ?? String(hostId) }];
if (!host) {
if (!synced) {
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
}
return <PageLayout nav={nav}>
<PageHeader title={String(hostId)} />
<p>Host with id {hostId} was not found on the controller.</p>
</PageLayout>;
return <ResourceState state={state} type="Host" id={hostId} nav={nav} listPath="/hosts" error={error} />;
}
let hostButtons = <Space wrap>
@@ -8,6 +8,8 @@ import FactorioIcon from "./FactorioIcon";
import { useParams } from "react-router-dom";
import { useModPack } from "../model/mod_pack";
import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store";
import useResourceState from "../util/useResourceState";
import ResourceState from "./ResourceState";
const { Text } = Typography;
@@ -39,7 +41,8 @@ export default function IconReferencePage() {
const [filter, setFilter] = useState("");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [modPack] = useModPack(modPackId);
const [modPack, synced, error] = useModPack(modPackId);
const state = useResourceState(modPack, synced, error, modPackId);
const manifest = modPack?.exportManifest;
const prototypes = useExportPrototypeMetadata(modPack);
const filtered = useMemo(() => filterPrototypes(prototypes, filter), [prototypes, filter]);
@@ -100,6 +103,10 @@ export default function IconReferencePage() {
{ name: modPack?.name ?? String(modPackId), path: `/mods/mod-packs/${modPackId}/view` },
{ name: "Icon Reference" },
];
if (!modPack) {
return <ResourceState state={state} type="Mod pack" id={modPackId} nav={nav} listPath="/mods" error={error} />;
}
return (
<PageLayout nav={nav}>
<PageHeader title="Icon Reference" />
@@ -1,7 +1,7 @@
import React, { useContext, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Alert, Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Segmented, Space, Spin, Tooltip, Typography,
Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Segmented, Space, Tooltip, Typography,
message,
} from "antd";
import CopyOutlined from "@ant-design/icons/CopyOutlined";
@@ -16,6 +16,7 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import ResourceNotes from "./ResourceNotes";
import ResourceState from "./ResourceState";
import InstanceConfigTree from "./InstanceConfigTree";
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
import InstanceRcon from "./InstanceRcon";
@@ -31,6 +32,7 @@ import InstanceStatusTag from "./InstanceStatusTag";
import Link from "./Link";
import { instancePublicAddress } from "../util/instance";
import useLocalStorage from "../util/useLocalStorage";
import useResourceState from "../util/useResourceState";
import { MetricRelativeDate } from "./system_metrics";
type MenuItem = Required<MenuProps>["items"][number];
@@ -208,26 +210,15 @@ export default function InstanceViewPage() {
let navigate = useNavigate();
let account = useAccount();
let [instance, synced] = useInstance(instanceId);
let [instance, synced, error] = useInstance(instanceId);
const state = useResourceState(instance, synced, error, instanceId);
let [host] = useHost(instance?.assignedHost);
let nav = [{ name: "Instances", path: "/instances" }, { name: instance?.name ?? String(instanceId) }];
if (!instance) {
if (!synced) {
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
}
return <PageLayout nav={nav}>
<Alert
message={"Instance not found"}
showIcon
description={<>Instance with id {instanceId} was not found on the controller.</>}
type="warning"
action={
<Link to="/instances">Go back to instances list</Link>
}
/>
</PageLayout>;
return <ResourceState
state={state} type="Instance" id={instanceId} nav={nav} listPath="/instances" error={error}
/>;
}
return <PageLayout nav={nav}>
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import {
Button, Card, ColorPicker, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination,
Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip,
Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Switch, Modal, Tooltip,
TablePaginationConfig, Alert, notification, FormProps,
} from "antd";
@@ -22,10 +22,12 @@ import { useModPack } from "../model/mod_pack";
import { useMods } from "../model/mods";
import { useClipboard } from "../util/clipboard";
import notify, { notifyErrorHandler } from "../util/notify";
import useResourceState from "../util/useResourceState";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import SectionHeader from "./SectionHeader";
import ResourceNotes from "./ResourceNotes";
import ResourceState from "./ResourceState";
import ModDetails from "./ModDetails";
import { InputPartialVersion } from "./InputVersion";
import Link from "./Link";
@@ -1183,7 +1185,8 @@ export default function ModPackViewPage() {
let modPackId = Number(params.id);
const [form] = Form.useForm();
const [modPack, synced] = useModPack(modPackId);
const [modPack, synced, error] = useModPack(modPackId);
const state = useResourceState(modPack, synced, error, modPackId);
let prototypes: any = useExportedAsset(modPack, "settings");
let locale: Map<any, any> = useExportedAsset(modPack, "locale");
let [changes, setChanges] = useState<ModChange[]>([]);
@@ -1253,17 +1256,7 @@ export default function ModPackViewPage() {
let nav = [{ name: "Mods", path: "/mods" }, { name: "Mod Packs" }, { name: modPack?.name ?? String(modPackId) }];
if (!modifiedModPack || !modPack) {
if (!synced) {
return <PageLayout nav={nav}>
<PageHeader title={String(modPackId)} />
<Spin size="large" />
</PageLayout>;
}
return <PageLayout nav={nav}>
<PageHeader title="Mod Pack Not Found" />
<p>Mod pack with id {modPackId} was not found on the controller.</p>
</PageLayout>;
return <ResourceState state={state} type="Mod pack" id={modPackId} nav={nav} listPath="/mods" error={error} />;
}
return <PageLayout nav={nav}>
@@ -35,6 +35,8 @@ type MachinePluginsState = {
unknown: Machine[],
/** Fetch the plugin lists of connected machines now, instead of waiting for the next system metrics */
refresh: (addresses?: lib.AddressShorthand[]) => Promise<void>,
/** Why machines is not available, if it's not just still loading */
error: Error | null,
};
/**
@@ -44,7 +46,7 @@ type MachinePluginsState = {
export function useMachinePlugins(): MachinePluginsState {
const control = useContext(ControlContext);
const account = useAccount();
const [hosts, hostsSynced] = useHosts();
const [hosts, hostsSynced, hostsError] = useHosts();
const [systems, systemsSynced] = useSystems();
// Lists fetched directly, used until the system metrics of the machine update
const [listed, setListed] = useState(new Map<string, ListedPlugins>());
@@ -88,8 +90,12 @@ export function useMachinePlugins(): MachinePluginsState {
}
}, [canList, hostsSynced, connectedKey]);
if (!canList || !hostsSynced || (!systemsSynced && !listed.size)) {
return { machines: undefined, unknown: [], refresh };
if (!canList) {
const error = new lib.PermissionError("Missing permission core.plugin.list");
return { machines: undefined, unknown: [], refresh, error };
}
if (!hostsSynced || (!systemsSynced && !listed.size)) {
return { machines: undefined, unknown: [], refresh, error: hostsError };
}
const machines: MachinePlugins[] = [];
const unknown: Machine[] = [];
@@ -105,7 +111,7 @@ export function useMachinePlugins(): MachinePluginsState {
unknown.push(machine);
}
}
return { machines, unknown, refresh };
return { machines, unknown, refresh, error: null };
}
/** Latest version published on npm of each package */
@@ -1,15 +1,15 @@
import React, { useEffect, useContext, useState } from "react";
import { useParams } from "react-router-dom";
import { Alert, Descriptions, Spin } from "antd";
import { Alert, Descriptions } from "antd";
import * as lib from "@clusterio/lib";
import type { PluginWebApi } from "@clusterio/lib";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import PageHeader from "./PageHeader";
import ResourceState from "./ResourceState";
import SectionHeader from "./SectionHeader";
import notify from "../util/notify";
import useResourceState from "../util/useResourceState";
import { usePluginInstaller } from "./PluginInstall";
import { PluginMachinesTable, PluginUpdateButton, useLatestVersions, useMachinePlugins } from "./PluginClusterStatus";
import { PluginStatus, computePluginStatuses, pluginStatusKey } from "../util/plugin_status";
@@ -89,18 +89,18 @@ export default function PluginViewPage() {
let params = useParams();
const control = useContext(ControlContext);
let [pluginList, setPluginList] = useState<PluginWebApi[]>();
const [pluginListError, setPluginListError] = useState<Error | null>(null);
let pluginName = String(params.name);
const { machines, unknown, refresh } = useMachinePlugins();
const { machines, unknown, refresh, error: machinesError } = useMachinePlugins();
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/plugins`);
if (response.ok) {
setPluginList(await response.json());
} else {
notify("Failed to load plugin list");
if (!response.ok) {
throw new Error(`Failed to load plugin list: ${response.status} ${response.statusText}`);
}
})();
setPluginList(await response.json());
})().catch(setPluginListError);
}, []);
const pluginMeta = pluginList?.find(p => p.name === pluginName);
@@ -110,19 +110,16 @@ export default function PluginViewPage() {
: undefined;
const pluginInfo = control.pluginInfos.get(pluginName);
const pluginTitle = pluginInfo?.title ?? status?.title ?? pluginName;
// Machines are only needed for plugins not on the controller, which isn't known until the list has loaded
const error = pluginListError ?? (pluginList ? machinesError : null);
const state = useResourceState(
pluginList && (pluginMeta ?? status), Boolean(pluginList && machines), error, pluginName
);
let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }];
if (!pluginList || (!pluginMeta && !machines)) {
return <PageLayout nav={nav}>
<Descriptions bordered size="small" title={pluginTitle} />
<Spin size="large" />
</PageLayout>;
}
if (!pluginMeta && !status) {
return <PageLayout nav={nav}>
<PageHeader title="Plugin not found" />
<p>Plugin with name {pluginName} was not found on the controller or any host.</p>
</PageLayout>;
if (state !== "ok") {
return <ResourceState
state={state} type="Plugin" id={pluginTitle} nav={nav} listPath="/plugins" error={error}
/>;
}
return <PageLayout nav={nav}>
@@ -0,0 +1,49 @@
import React from "react";
import { Result, ResultProps, Spin } from "antd";
import Link from "./Link";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import type { ResourceStatus } from "../util/useResourceState";
type ResourceStateProps = {
/** Status from useResourceState */
state: ResourceStatus;
/** Capitalised name of the kind of resource, e.g. "Mod pack" */
type: string;
/** Id or name of the resource */
id: number | string;
nav: React.ComponentProps<typeof PageLayout>["nav"];
/** Page listing this kind of resource */
listPath: string;
/** Error from the subscription */
error: Error | null;
};
/** Whole page to render in place of a view page when its resource can't be shown */
export default function ResourceState(props: ResourceStateProps) {
const { state, type, id, nav } = props;
if (state === "ok") {
return null;
}
if (state === "loading") {
return <PageLayout nav={nav}>
<PageHeader title={String(id)} />
<Spin size="large" />
</PageLayout>;
}
const results: Record<typeof state, ResultProps> = {
missing: { status: "warning", title: `${type} not found`, subTitle: `${type} ${id} was not found.` },
deleted: { status: "warning", title: `${type} ${id} has been deleted` },
error: { status: "error", title: `Could not load ${type.toLowerCase()}`, subTitle: props.error?.message },
};
return <PageLayout nav={nav}>
<Result
{...results[state]}
extra={<Link to={props.listPath}>Back to {type.toLowerCase()} list</Link>}
/>
</PageLayout>;
}
@@ -1,6 +1,6 @@
import React, { useEffect, useContext, useState, useMemo, useRef } from "react";
import React, { useEffect, useContext, useState, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button, Checkbox, Col, Grid, Input, Popconfirm, Row, Space, Spin, Tooltip } from "antd";
import { Button, Checkbox, Col, Grid, Input, Popconfirm, Row, Space, Tooltip } from "antd";
import { ExclamationCircleOutlined, StarOutlined, DeleteOutlined} from "@ant-design/icons";
import * as lib from "@clusterio/lib";
@@ -12,7 +12,9 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import ResourceNotes from "./ResourceNotes";
import ResourceState from "./ResourceState";
import { notifyErrorHandler } from "../util/notify";
import useResourceState from "../util/useResourceState";
/** Splits a permission name into parts, removing the last part */
@@ -162,14 +164,14 @@ export default function RoleViewPage() {
const navigate = useNavigate();
const account = useAccount();
const control = useContext(ControlContext);
const [role, synced] = useRole(roleId);
const [role, synced, error] = useRole(roleId);
const state = useResourceState(role, synced, error, roleId);
// Local edits on top of the live role
const [nameEdit, setNameEdit] = useState<string>();
const [descriptionEdit, setDescriptionEdit] = useState<string>();
const [permissionEdits, setPermissionEdits] = useState<Record<string, boolean>>({});
const [search, setSearch] = useState("");
const loadedRoleId = useRef<number>(undefined);
// Hidden below lg, where the site sider leaves too little room; undefined until measured
const screens = Grid.useBreakpoint();
const showGroupList = screens.lg !== false;
@@ -203,27 +205,9 @@ export default function RoleViewPage() {
setPermissionEdits({});
}, [roleId]);
if (role) {
loadedRoleId.current = roleId;
}
// If no role then display either the loading screen or the not found screen
if (!role) {
const nav = [{ name: "Roles", path: "/roles" }, { name: String(roleId) }];
if (!synced) {
return <PageLayout nav={nav}>
<PageHeader title={String(roleId)} />
<Spin size="large" />
</PageLayout>;
}
return <PageLayout nav={nav}>
<PageHeader title="Role not found" />
<p>Role with id {roleId} {
loadedRoleId.current === roleId ? "has been deleted" : "was not found on the controller"
}.</p>
</PageLayout>;
return <ResourceState state={state} type="Role" id={roleId} nav={nav} listPath="/roles" error={error} />;
}
const name = nameEdit ?? role.name;
@@ -2,7 +2,7 @@ import React, { useEffect, useContext, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Button, Col, Descriptions, Form, Input, Popconfirm,
Row, Table, Tag, Select, Space, Spin, Switch,
Row, Table, Tag, Select, Space, Switch,
} from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
@@ -18,7 +18,9 @@ import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import SectionHeader from "./SectionHeader";
import ResourceNotes from "./ResourceNotes";
import ResourceState from "./ResourceState";
import useRowNavigation from "../util/useRowNavigation";
import useResourceState from "../util/useResourceState";
import Link from "./Link";
import notify, { notifyErrorHandler } from "../util/notify";
import { formatDuration } from "../util/time_format";
@@ -40,7 +42,8 @@ export default function UserViewPage() {
let account = useAccount();
let control = useContext(ControlContext);
let [instances] = useInstances();
const [user, synced] = useUser(userName);
const [user, synced, error] = useUser(userName);
const state = useResourceState(user, synced, error, userName);
let [roles, rolesSynced] = useRoles();
let [form] = Form.useForm();
const statsTable = useTableQueryState<[number, lib.PlayerStats]>({
@@ -78,17 +81,7 @@ export default function UserViewPage() {
let nav = [{ name: "Users", path: "/users" }, { name: userName }];
if (!user) {
if (!synced) {
return <PageLayout nav={nav}>
<PageHeader title={userName} />
<Spin size="large" />
</PageLayout>;
}
return <PageLayout nav={nav}>
<PageHeader title="User not found" />
<p>User with name {userName} was not found on the controller.</p>
</PageLayout>;
return <ResourceState state={state} type="User" id={userName} nav={nav} listPath="/users" error={error} />;
}
let roleSelector = <Form.Item
+2
View File
@@ -40,6 +40,7 @@ export { default as PageLayout } from "./components/PageLayout";
export { default as SavesList } from "./components/SavesList";
export { default as SectionHeader } from "./components/SectionHeader";
export { default as ResourceNotes } from "./components/ResourceNotes";
export { default as ResourceState } from "./components/ResourceState";
export { default as Link } from "./components/Link";
@@ -48,3 +49,4 @@ export { default as FactorioIcon } from "./components/FactorioIcon";
export { default as useTableQueryState } from "./util/useTableQueryState";
export { default as useColumnSearch } from "./util/useColumnSearch";
export { default as useRowNavigation } from "./util/useRowNavigation";
export { default as useResourceState, type ResourceStatus } from "./util/useResourceState";
+2 -2
View File
@@ -2,8 +2,8 @@ import { useCallback, useContext, useSyncExternalStore } from "react";
import ControlContext from "../components/ControlContext";
export function useHost(id?: number) {
const [hosts, synced] = useHosts();
return [id !== undefined ? hosts.get(id) : undefined, synced] as const;
const [hosts, synced, error] = useHosts();
return [id !== undefined ? hosts.get(id) : undefined, synced, error] as const;
}
export function useHosts() {
+2 -2
View File
@@ -4,8 +4,8 @@ import { Config, InstanceConfigGetRequest } from "@clusterio/lib";
import ControlContext from "../components/ControlContext";
export function useInstance(id?: number) {
const [instances, synced] = useInstances();
return [id !== undefined ? instances.get(id) : undefined, synced] as const;
const [instances, synced, error] = useInstances();
return [id !== undefined ? instances.get(id) : undefined, synced, error] as const;
}
export function useInstances() {
+2 -2
View File
@@ -4,8 +4,8 @@ import { defaultModPackStore } from "../store/default_mod_pack_store";
export function useModPack(id?: number) {
const [modPacks, synced] = useModPacks();
return [id !== undefined ? modPacks.get(id) : undefined, synced] as const;
const [modPacks, synced, error] = useModPacks();
return [id !== undefined ? modPacks.get(id) : undefined, synced, error] as const;
}
export function useModPacks() {
+2 -2
View File
@@ -12,8 +12,8 @@ export function useRolesOfUser(user?: lib.UserDetails) {
}
export function useRole(id?: number) {
const [roles, synced] = useRoles();
return [id !== undefined ? roles.get(id) : undefined, synced] as const;
const [roles, synced, error] = useRoles();
return [id !== undefined ? roles.get(id) : undefined, synced, error] as const;
}
export function useRoles() {
+2 -2
View File
@@ -89,8 +89,8 @@ export function sortLastSeen(
}
export function useUser(name?: string) {
const [users, synced] = useUsers();
return [name !== undefined ? users.get(name.toLowerCase()) : undefined, synced] as const;
const [users, synced, error] = useUsers();
return [name !== undefined ? users.get(name.toLowerCase()) : undefined, synced, error] as const;
}
export function useUsers() {
@@ -0,0 +1,37 @@
import { useRef } from "react";
export type ResourceStatus = "loading" | "ok" | "missing" | "deleted" | "error";
export function getResourceStatus(
exists: boolean,
seen: boolean,
synced: boolean,
error: Error | null,
): ResourceStatus {
if (exists) {
return "ok";
}
// A retry only clears the error when its response arrives, which can be after the update that synced it
if (synced) {
return seen ? "deleted" : "missing";
}
return error ? "error" : "loading";
}
/**
* Status of the resource shown on a view page, for use with the ResourceState component
*
* @param value - The resource, undefined or null if the subscription does not have it.
* @param synced - The synced property from the subscription.
* @param error - The error property from the subscription.
* @param id - Identifies the resource shown, the router reuses the page when navigating between resources.
* @returns "ok" if there is a value to show, otherwise why there isn't.
*/
export default function useResourceState(value: unknown, synced: boolean, error: Error | null, id: number | string) {
const seenId = useRef<number | string>(undefined);
const exists = value !== undefined && value !== null;
if (exists) {
seenId.current = id;
}
return getResourceStatus(exists, seenId.current === id, synced, error);
}
+37
View File
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { getResourceStatus } from "../../packages/web_ui/src/util/useResourceState.ts";
describe("web_ui/util/useResourceState", function() {
describe("getResourceStatus()", function() {
const error = new Error("Permission denied");
it("should be ok when the resource exists", function() {
assert.equal(getResourceStatus(true, true, true, null), "ok");
});
it("should keep showing a resource after the subscription stopped syncing", function() {
assert.equal(getResourceStatus(true, true, false, null), "ok");
assert.equal(getResourceStatus(true, true, false, error), "ok");
});
it("should be loading until the subscription has synced", function() {
assert.equal(getResourceStatus(false, false, false, null), "loading");
});
it("should be loading while resyncing a resource that was seen", function() {
assert.equal(getResourceStatus(false, true, false, null), "loading");
});
it("should be missing when synced without having seen the resource", function() {
assert.equal(getResourceStatus(false, false, true, null), "missing");
});
it("should be deleted when synced after having seen the resource", function() {
assert.equal(getResourceStatus(false, true, true, null), "deleted");
});
it("should be error when the subscription failed", function() {
assert.equal(getResourceStatus(false, false, false, error), "error");
assert.equal(getResourceStatus(false, true, false, error), "error");
});
it("should ignore the error of an earlier attempt once synced", function() {
assert.equal(getResourceStatus(false, false, true, error), "missing");
assert.equal(getResourceStatus(false, true, true, error), "deleted");
});
});
});