Merge remote-tracking branch 'origin/v3.4' into dev

This commit is contained in:
proddy
2022-01-23 17:56:52 +01:00
parent 02e2b51814
commit 77e1898512
538 changed files with 32282 additions and 38655 deletions

View File

@@ -1,8 +1,8 @@
type UpdateEntity<S> = (state: (prevState: Readonly<S>) => S) => void;
export const extractEventValue = (
event: React.ChangeEvent<HTMLInputElement>
) => {
export const numberValue = (value: number) => (isNaN(value) ? '' : value.toString());
export const extractEventValue = (event: React.ChangeEvent<HTMLInputElement>) => {
switch (event.target.type) {
case 'number':
return event.target.valueAsNumber;
@@ -13,21 +13,11 @@ export const extractEventValue = (
}
};
export const updateValue = <S>(updateEntity: UpdateEntity<S>) => (
event: React.ChangeEvent<HTMLInputElement>
) => {
updateEntity((prevState) => ({
...prevState,
[event.target.name]: extractEventValue(event)
}));
};
export const updateBooleanValue = <S>(updateEntity: UpdateEntity<S>) => (
name: string,
value?: boolean
) => {
updateEntity((prevState) => ({
...prevState,
[name]: value
}));
};
export const updateValue =
<S>(updateEntity: UpdateEntity<S>) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
updateEntity((prevState) => ({
...prevState,
[event.target.name]: extractEventValue(event)
}));
};

View File

@@ -0,0 +1,4 @@
import { AxiosError } from 'axios';
export const extractErrorMessage = (error: AxiosError, defaultMessage: string) =>
(error.response && error.response.data ? error.response.data.message : error.message) || defaultMessage;

View File

@@ -1 +1,6 @@
export * from './binding';
export * from './endpoints';
export * from './route';
export * from './submit';
export * from './time';
export * from './useRest';

View File

@@ -0,0 +1 @@
export const routeMatches = (route: string, pathname: string) => pathname.startsWith(route + '/') || pathname === route;

View File

@@ -0,0 +1,8 @@
export const onEnterCallback =
(callback: () => void): ((event: React.KeyboardEvent) => void) =>
(event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
callback();
event.preventDefault();
}
};

View File

@@ -0,0 +1,40 @@
import parseMilliseconds from 'parse-ms';
const LOCALE_FORMAT = new Intl.DateTimeFormat([...window.navigator.languages], {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: false
});
export const formatDateTime = (dateTime: string) => {
return LOCALE_FORMAT.format(new Date(dateTime.substr(0, 19)));
};
export const formatLocalDateTime = (date: Date) => {
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, -1).substr(0, 19);
};
export const formatDuration = (duration: number) => {
const { days, hours, minutes, seconds } = parseMilliseconds(duration * 1000);
var formatted = '';
if (days) {
formatted += pluralize(days, 'day');
}
if (formatted || hours) {
formatted += pluralize(hours, 'hour');
}
if (formatted || minutes) {
formatted += pluralize(minutes, 'minute');
}
if (formatted || seconds) {
formatted += pluralize(seconds, 'second');
}
return formatted;
};
const pluralize = (count: number, noun: string, suffix: string = 's') =>
` ${count} ${noun}${count !== 1 ? suffix : ''} `;

View File

@@ -0,0 +1,66 @@
import { useCallback, useEffect, useState } from 'react';
import { useSnackbar } from 'notistack';
import { AxiosPromise } from 'axios';
import { extractErrorMessage } from '.';
export interface RestRequestOptions<D> {
read: () => AxiosPromise<D>;
update?: (value: D) => AxiosPromise<D>;
}
export const useRest = <D>({ read, update }: RestRequestOptions<D>) => {
const { enqueueSnackbar } = useSnackbar();
const [saving, setSaving] = useState<boolean>(false);
const [data, setData] = useState<D>();
const [errorMessage, setErrorMessage] = useState<string>();
const [restartNeeded, setRestartNeeded] = useState<boolean>(false);
const loadData = useCallback(async () => {
setData(undefined);
setErrorMessage(undefined);
try {
setData((await read()).data);
} catch (error: any) {
const message = extractErrorMessage(error, 'Problem loading data');
enqueueSnackbar(message, { variant: 'error' });
setErrorMessage(message);
}
}, [read, enqueueSnackbar]);
const save = useCallback(
async (toSave: D) => {
if (!update) {
return;
}
setSaving(true);
setRestartNeeded(false);
setErrorMessage(undefined);
try {
const response = await update(toSave);
setData(response.data);
if (response.status === 202) {
setRestartNeeded(true);
} else {
enqueueSnackbar('Settings saved', { variant: 'success' });
}
} catch (error: any) {
const message = extractErrorMessage(error, 'Problem saving data');
enqueueSnackbar(message, { variant: 'error' });
setErrorMessage(message);
} finally {
setSaving(false);
}
},
[update, enqueueSnackbar]
);
const saveData = () => data && save(data);
useEffect(() => {
loadData();
}, [loadData]);
return { loadData, saveData, saving, setData, data, errorMessage, restartNeeded } as const;
};

View File

@@ -0,0 +1,94 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import Sockette from 'sockette';
import { debounce } from 'lodash';
import { addAccessTokenParameter } from '../api/authentication';
interface WebSocketIdMessage {
type: 'id';
id: string;
}
interface WebSocketPayloadMessage<D> {
type: 'payload';
origin_id: string;
payload: D;
}
export type WebSocketMessage<D> = WebSocketIdMessage | WebSocketPayloadMessage<D>;
export const useWs = <D>(wsUrl: string, wsThrottle: number = 100) => {
const ws = useRef<Sockette>();
const clientId = useRef<string>();
const [connected, setConnected] = useState<boolean>(false);
const [data, setData] = useState<D>();
const [transmit, setTransmit] = useState<boolean>();
const [clear, setClear] = useState<boolean>();
const onMessage = useCallback((event: MessageEvent) => {
const rawData = event.data;
if (typeof rawData === 'string' || rawData instanceof String) {
const message = JSON.parse(rawData as string) as WebSocketMessage<D>;
switch (message.type) {
case 'id':
clientId.current = message.id;
break;
case 'payload':
if (clientId.current) {
setData((existingData) => (clientId.current === message.origin_id && existingData) || message.payload);
}
break;
}
}
}, []);
const doSaveData = useCallback((newData: D, clearData: boolean = false) => {
if (!ws.current) {
return;
}
if (clearData) {
setData(undefined);
}
ws.current.json(newData);
}, []);
const saveData = useRef(debounce(doSaveData, wsThrottle));
const updateData = (
newData: React.SetStateAction<D | undefined>,
transmitData: boolean = true,
clearData: boolean = false
) => {
setData(newData);
setTransmit(transmitData);
setClear(clearData);
};
useEffect(() => {
if (!transmit) {
return;
}
data && saveData.current(data, clear);
setTransmit(false);
setClear(false);
}, [doSaveData, data, transmit, clear]);
useEffect(() => {
const instance = new Sockette(addAccessTokenParameter(wsUrl), {
onmessage: onMessage,
onopen: () => {
setConnected(true);
},
onclose: () => {
clientId.current = undefined;
setConnected(false);
setData(undefined);
}
});
ws.current = instance;
return instance.close;
}, [wsUrl, onMessage]);
return { connected, data, updateData } as const;
};