Added live updates to spools in client
Will automatically refresh the list and show pages if any of the shown spools are updated in the backend. On the edit page, it will warn you if the spool has been updated since you opened the page.
This commit is contained in:
@@ -165,7 +165,8 @@
|
|||||||
"archive": "Archive Spool"
|
"archive": "Archive Spool"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"new_location_prompt": "Enter a new location"
|
"new_location_prompt": "Enter a new location",
|
||||||
|
"spool_updated": "This spool has been updated by someone/something else since you opened this page. Saving will overwrite those changes!"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"archive": "Are you sure you want to archive this spool?"
|
"archive": "Are you sure you want to archive this spool?"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { languages } from "./i18n";
|
|||||||
import loadable from "@loadable/component";
|
import loadable from "@loadable/component";
|
||||||
import SpoolmanNotificationProvider from "./components/notificationProvider";
|
import SpoolmanNotificationProvider from "./components/notificationProvider";
|
||||||
import { SpoolmanLayout } from "./components/layout";
|
import { SpoolmanLayout } from "./components/layout";
|
||||||
|
import liveProvider from "./components/liveProvider";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
resource: "spools" | "filaments" | "vendors";
|
resource: "spools" | "filaments" | "vendors";
|
||||||
@@ -84,6 +85,7 @@ function App() {
|
|||||||
notificationProvider={SpoolmanNotificationProvider}
|
notificationProvider={SpoolmanNotificationProvider}
|
||||||
i18nProvider={i18nProvider}
|
i18nProvider={i18nProvider}
|
||||||
routerProvider={routerBindings}
|
routerProvider={routerBindings}
|
||||||
|
liveProvider={liveProvider(import.meta.env.VITE_APIURL)}
|
||||||
resources={[
|
resources={[
|
||||||
{
|
{
|
||||||
name: "spool",
|
name: "spool",
|
||||||
|
|||||||
109
client/src/components/liveProvider.ts
Normal file
109
client/src/components/liveProvider.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { BaseKey, LiveEvent, LiveProvider } from "@refinedev/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an API URL to a WebSocket URL.
|
||||||
|
* E.g. "https://example.com/api/v1/..." -> "ws://example.com/api/v1/..."
|
||||||
|
* or "/api/v1/..." -> "ws://example.com/api/v1/..."
|
||||||
|
* @param apiUrl The API URL to convert
|
||||||
|
* @returns The WebSocket URL
|
||||||
|
*/
|
||||||
|
function toWebsocketURL(apiUrl: string) {
|
||||||
|
if (apiUrl[0] === "/") {
|
||||||
|
// Relative URL, e.g. "/api/v1/..."
|
||||||
|
|
||||||
|
// Get the current browser URL
|
||||||
|
const currentURL = window.location.href;
|
||||||
|
|
||||||
|
// Split the URL to separate the protocol, host, and path
|
||||||
|
const urlParts = currentURL.split('/');
|
||||||
|
const host = urlParts[2];
|
||||||
|
|
||||||
|
// Create the WebSocket URL by adding "ws://" as the protocol and the relative URL as the path
|
||||||
|
return `ws://${host}${apiUrl}`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Absolute URL, e.g. "https://example.com/api/v1/..."
|
||||||
|
|
||||||
|
// Replace the protocol with "ws://"
|
||||||
|
return apiUrl.replace(/^http/, 'ws');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to a single resource.
|
||||||
|
* @param apiUrl The API URL
|
||||||
|
* @param channel The channel name, not really used
|
||||||
|
* @param resource The resource name
|
||||||
|
* @param id The ID of the resource
|
||||||
|
* @param callback The callback to call when the resource is updated
|
||||||
|
* @returns A function to unsubscribe from the resource
|
||||||
|
*/
|
||||||
|
function subscribeSingle(apiUrl: string, channel: string, resource: string, id: BaseKey, callback: (event: LiveEvent) => void) {
|
||||||
|
// Verify that WebSockets are supported
|
||||||
|
if (!('WebSocket' in window)) {
|
||||||
|
console.warn("WebSockets are not supported in this browser. Live updates will not be available.");
|
||||||
|
return () => { };
|
||||||
|
}
|
||||||
|
|
||||||
|
const websocketURL = toWebsocketURL(`${apiUrl}/${resource}/${id}`);
|
||||||
|
|
||||||
|
const ws = new WebSocket(websocketURL);
|
||||||
|
ws.onmessage = (message) => {
|
||||||
|
const liveEvent: LiveEvent = {
|
||||||
|
channel: channel,
|
||||||
|
type: "updated",
|
||||||
|
payload: {
|
||||||
|
data: JSON.parse(message.data),
|
||||||
|
ids: [id],
|
||||||
|
},
|
||||||
|
date: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(liveEvent);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveProvider = (
|
||||||
|
apiUrl: string,
|
||||||
|
): LiveProvider => ({
|
||||||
|
subscribe: ({ channel, params, callback }) => {
|
||||||
|
const {
|
||||||
|
resource,
|
||||||
|
subscriptionType,
|
||||||
|
id,
|
||||||
|
ids,
|
||||||
|
} = params ?? {};
|
||||||
|
|
||||||
|
if (!subscriptionType) {
|
||||||
|
throw new Error(
|
||||||
|
"[useSubscription]: `subscriptionType` is required in `params`",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
throw new Error(
|
||||||
|
"[useSubscription]: `resource` is required in `params`",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let idList: BaseKey[];
|
||||||
|
if (ids) idList = ids;
|
||||||
|
else if (id) idList = [id];
|
||||||
|
else throw new Error(
|
||||||
|
"[useSubscription]: `id` or `ids` is required in `params`",
|
||||||
|
);
|
||||||
|
|
||||||
|
return idList.map((id) => {
|
||||||
|
return subscribeSingle(apiUrl, channel, resource, id, callback);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
unsubscribe: (closers: (() => void)[]) => {
|
||||||
|
closers.forEach((fn) => fn());
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default liveProvider;
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
|
import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
|
||||||
import { Edit, useForm, useSelect } from "@refinedev/antd";
|
import { Edit, useForm, useSelect } from "@refinedev/antd";
|
||||||
import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider } from "antd";
|
import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider, Alert } from "antd";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import TextArea from "antd/es/input/TextArea";
|
import TextArea from "antd/es/input/TextArea";
|
||||||
import { IFilament } from "../filaments/model";
|
import { IFilament } from "../filaments/model";
|
||||||
import { ISpool } from "./model";
|
import { ISpool } from "./model";
|
||||||
import { numberFormatter, numberParser } from "../../utils/parsing";
|
import { numberFormatter, numberParser } from "../../utils/parsing";
|
||||||
import { useSpoolmanLocations } from "../../components/otherModels";
|
import { useSpoolmanLocations } from "../../components/otherModels";
|
||||||
|
import { message } from "antd/lib";
|
||||||
|
|
||||||
export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
const [hasChanged, setHasChanged] = useState(false);
|
||||||
|
|
||||||
const { form, formProps, saveButtonProps } = useForm<ISpool>();
|
const { form, formProps, saveButtonProps } = useForm<ISpool>({
|
||||||
|
liveMode: "manual",
|
||||||
|
onLiveEvent() {
|
||||||
|
// Warn the user if the spool has been updated since the form was opened
|
||||||
|
messageApi.warning(t("spool.form.spool_updated"));
|
||||||
|
setHasChanged(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { queryResult } = useSelect<IFilament>({
|
const { queryResult } = useSelect<IFilament>({
|
||||||
resource: "filament",
|
resource: "filament",
|
||||||
@@ -96,6 +106,7 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit saveButtonProps={saveButtonProps}>
|
<Edit saveButtonProps={saveButtonProps}>
|
||||||
|
{contextHolder}
|
||||||
<Form {...formProps} layout="vertical">
|
<Form {...formProps} layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("spool.fields.id")}
|
label={t("spool.fields.id")}
|
||||||
@@ -282,6 +293,7 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
|||||||
<TextArea maxLength={1024} />
|
<TextArea maxLength={1024} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
{hasChanged && <Alert description={t("spool.form.spool_updated")} type="warning" showIcon />}
|
||||||
</Edit>
|
</Edit>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { IResourceComponentsProps, useInvalidate, useTranslate } from "@refinedev/core";
|
import { IResourceComponentsProps, LiveEvent, useInvalidate, useTranslate } from "@refinedev/core";
|
||||||
import { useTable, List, EditButton, ShowButton, CloneButton } from "@refinedev/antd";
|
import { useTable, List, EditButton, ShowButton, CloneButton } from "@refinedev/antd";
|
||||||
import { Table, Space, Button, Dropdown, Modal } from "antd";
|
import { Table, Space, Button, Dropdown, Modal } from "antd";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
useSpoolmanLotNumbers,
|
useSpoolmanLotNumbers,
|
||||||
useSpoolmanMaterials,
|
useSpoolmanMaterials,
|
||||||
} from "../../components/otherModels";
|
} from "../../components/otherModels";
|
||||||
|
import liveProvider from "../../components/liveProvider";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
@@ -34,6 +35,21 @@ interface ISpoolCollapsed extends ISpool {
|
|||||||
"filament.material"?: string;
|
"filament.material"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collapseSpool(element: ISpool): ISpoolCollapsed {
|
||||||
|
let filament_name: string;
|
||||||
|
if (element.filament.vendor && "name" in element.filament.vendor) {
|
||||||
|
filament_name = `${element.filament.vendor.name} - ${element.filament.name}`;
|
||||||
|
} else {
|
||||||
|
filament_name = element.filament.name ?? element.filament.id.toString();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...element,
|
||||||
|
combined_name: filament_name,
|
||||||
|
"filament.id": element.filament.id,
|
||||||
|
"filament.material": element.filament.material,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function translateColumnI18nKey(columnName: string): string {
|
function translateColumnI18nKey(columnName: string): string {
|
||||||
columnName = columnName.replace(".", "_");
|
columnName = columnName.replace(".", "_");
|
||||||
if (columnName === "combined_name") columnName = "filament_name";
|
if (columnName === "combined_name") columnName = "filament_name";
|
||||||
@@ -43,6 +59,61 @@ function translateColumnI18nKey(columnName: string): string {
|
|||||||
|
|
||||||
const namespace = "spoolList-v2";
|
const namespace = "spoolList-v2";
|
||||||
|
|
||||||
|
const liveProviderInstance = liveProvider(import.meta.env.VITE_APIURL);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that subscribes to live updates for the spools in the dataSource
|
||||||
|
* @param dataSource Original dataSource
|
||||||
|
* @returns dataSource that is updated with live data
|
||||||
|
*/
|
||||||
|
function useLiveify(dataSource: ISpoolCollapsed[]) {
|
||||||
|
// TODO: The hooks in this function is quite janky, and should be refactored to be more efficient
|
||||||
|
// New state that holds the dataSource with updated values from the live provider
|
||||||
|
const [updatedDataSource, setUpdatedDataSource] = React.useState<ISpoolCollapsed[]>(dataSource);
|
||||||
|
|
||||||
|
// If the original dataSource changes, update the updatedDataSource
|
||||||
|
React.useEffect(() => {
|
||||||
|
setUpdatedDataSource(dataSource);
|
||||||
|
}, [dataSource]);
|
||||||
|
|
||||||
|
// Create a constant reference to itemIds. This is to prevent the useEffect below from triggering extra times.
|
||||||
|
const itemIds = dataSource.map((item) => item.id);
|
||||||
|
const [prevItemIds, setPrevItemIds] = React.useState<number[]>(itemIds);
|
||||||
|
if (JSON.stringify(itemIds) !== JSON.stringify(prevItemIds)) {
|
||||||
|
setPrevItemIds(itemIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to changes for all items in the dataSource
|
||||||
|
React.useEffect(() => {
|
||||||
|
const subscription = liveProviderInstance?.subscribe({
|
||||||
|
channel: "spool-list",
|
||||||
|
params: {
|
||||||
|
resource: "spool",
|
||||||
|
ids: prevItemIds,
|
||||||
|
subscriptionType: "useList",
|
||||||
|
},
|
||||||
|
types: ["update"],
|
||||||
|
callback: (event: LiveEvent) => {
|
||||||
|
const data = event.payload.data as ISpool;
|
||||||
|
setUpdatedDataSource((prev) =>
|
||||||
|
prev.map((item) => {
|
||||||
|
return item.id === data.id ? collapseSpool(data) : item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unsubscribe when the component unmounts
|
||||||
|
return () => {
|
||||||
|
if (subscription) {
|
||||||
|
liveProviderInstance?.unsubscribe(subscription);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [prevItemIds]);
|
||||||
|
|
||||||
|
return updatedDataSource;
|
||||||
|
}
|
||||||
|
|
||||||
export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const invalidate = useInvalidate();
|
const invalidate = useInvalidate();
|
||||||
@@ -54,6 +125,9 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
|||||||
const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false);
|
const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false);
|
||||||
|
|
||||||
// Fetch data from the API
|
// Fetch data from the API
|
||||||
|
// To provide the live updates, we use a custom solution (useLiveify) instead of the built-in refine "liveMode" feature.
|
||||||
|
// This is because the built-in feature does not call the liveProvider subscriber with a list of IDs, but instead
|
||||||
|
// calls it with a list of filters, sorters, etc. This means the server-side has to support this, which is quite hard.
|
||||||
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } = useTable<ISpool>({
|
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } = useTable<ISpool>({
|
||||||
meta: {
|
meta: {
|
||||||
queryParams: {
|
queryParams: {
|
||||||
@@ -107,23 +181,8 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
|||||||
useStoreInitialState(namespace, tableState);
|
useStoreInitialState(namespace, tableState);
|
||||||
|
|
||||||
// Collapse the dataSource to a mutable list and add a filament_name field
|
// Collapse the dataSource to a mutable list and add a filament_name field
|
||||||
const dataSource: ISpoolCollapsed[] = React.useMemo(
|
const dataSource: ISpoolCollapsed[] = useLiveify(
|
||||||
() =>
|
React.useMemo(() => (tableProps.dataSource ?? []).map(collapseSpool), [tableProps.dataSource])
|
||||||
(tableProps.dataSource ?? []).map((element) => {
|
|
||||||
let filament_name: string;
|
|
||||||
if (element.filament.vendor && "name" in element.filament.vendor) {
|
|
||||||
filament_name = `${element.filament.vendor.name} - ${element.filament.name}`;
|
|
||||||
} else {
|
|
||||||
filament_name = element.filament.name ?? element.filament.id.toString();
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...element,
|
|
||||||
combined_name: filament_name,
|
|
||||||
"filament.id": element.filament.id,
|
|
||||||
"filament.material": element.filament.material,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
[tableProps.dataSource]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Function for opening an ant design modal that asks for confirmation for archiving a spool
|
// Function for opening an ant design modal that asks for confirmation for archiving a spool
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ const { Title } = Typography;
|
|||||||
export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
|
||||||
const { queryResult } = useShow<ISpool>();
|
const { queryResult } = useShow<ISpool>({
|
||||||
|
liveMode: "auto",
|
||||||
|
});
|
||||||
const { data, isLoading } = queryResult;
|
const { data, isLoading } = queryResult;
|
||||||
|
|
||||||
const record = data?.data;
|
const record = data?.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user