TeaWeb/shared/js/ui/utils/Variable.ts

398 lines
14 KiB
TypeScript
Raw Normal View History

2021-01-17 22:11:21 +00:00
import {useEffect, useState} from "react";
import * as _ from "lodash";
2021-03-13 19:10:00 +00:00
import {ReadonlyKeys, WritableKeys} from "tc-shared/proto";
import {useDependentState} from "tc-shared/ui/react-elements/Helper";
2021-01-17 22:11:21 +00:00
/*
* To deliver optimized performance, we only promisify the values we need.
* If done so, we have the change to instantaneously load local values without needing
* to rerender the ui.
*/
export type UiVariable = Transferable | undefined | null | number | string | object;
export type UiVariableMap = { [key: string]: any }; //UiVariable | Readonly<UiVariable>
2021-01-21 16:54:55 +00:00
export type ReadonlyVariables<Variables extends UiVariableMap> = Pick<Variables, ReadonlyKeys<Variables>>
export type WriteableVariables<Variables extends UiVariableMap> = Pick<Variables, WritableKeys<Variables>>
2021-01-17 22:11:21 +00:00
type UiVariableEditor<Variables extends UiVariableMap, T extends keyof Variables> = Variables[T] extends { __readonly } ?
never :
(newValue: Variables[T], customData: any) => Variables[T] | void | boolean;
type UiVariableEditorPromise<Variables extends UiVariableMap, T extends keyof Variables> = Variables[T] extends { __readonly } ?
never :
(newValue: Variables[T], customData: any) => Promise<Variables[T] | void | boolean>;
export abstract class UiVariableProvider<Variables extends UiVariableMap> {
private variableProvider: {[key: string]: (customData: any) => any | Promise<any>} = {};
private variableEditor: {[key: string]: (newValue, customData: any) => any | Promise<any>} = {};
protected constructor() { }
destroy() { }
setVariableProvider<T extends keyof Variables>(variable: T, provider: (customData: any) => Variables[T] | Promise<Variables[T]>) {
this.variableProvider[variable as any] = provider;
}
2021-02-19 22:05:48 +00:00
/**
* @param variable
* @param editor If the editor returns `false` or a new variable, such variable will be used
*/
2021-01-17 22:11:21 +00:00
setVariableEditor<T extends keyof Variables>(variable: T, editor: UiVariableEditor<Variables, T>) {
this.variableEditor[variable as any] = editor;
}
setVariableEditorAsync<T extends keyof Variables>(variable: T, editor: UiVariableEditorPromise<Variables, T>) {
this.variableEditor[variable as any] = editor;
}
/**
* Send/update a variable
* @param variable The target variable to send.
* @param customData
* @param forceSend If `true` the variable will be send event though it hasn't changed.
*/
sendVariable<T extends keyof Variables>(variable: T, customData?: any, forceSend?: boolean) : void | Promise<void> {
const providers = this.variableProvider[variable as any];
if(!providers) {
2021-01-21 16:54:55 +00:00
throw tra("missing provider for {}", variable);
2021-01-17 22:11:21 +00:00
}
const result = providers(customData);
if(result instanceof Promise) {
return result
.then(result => this.doSendVariable(variable as any, customData, result))
.catch(error => {
console.error(error);
});
} else {
this.doSendVariable(variable as any, customData, result);
}
}
async getVariable<T extends keyof Variables>(variable: T, customData?: any, ignoreCache?: boolean) : Promise<Variables[T]> {
const providers = this.variableProvider[variable as any];
if(!providers) {
2021-01-21 16:54:55 +00:00
throw tra("missing provider for {}", variable);
2021-01-17 22:11:21 +00:00
}
const result = providers(customData);
if(result instanceof Promise) {
return await result;
} else {
return result;
}
}
getVariableSync<T extends keyof Variables>(variable: T, customData?: any, ignoreCache?: boolean) : Variables[T] {
const providers = this.variableProvider[variable as any];
if(!providers) {
throw tr("missing provider");
}
const result = providers(customData);
if(result instanceof Promise) {
throw tr("tried to get an async variable synchronous");
}
return result;
}
protected resolveVariable(variable: string, customData: any): Promise<any> | any {
const providers = this.variableProvider[variable];
if(!providers) {
throw tr("missing provider");
}
return providers(customData);
}
protected doEditVariable(variable: string, customData: any, newValue: any) : Promise<void> | void {
const editor = this.variableEditor[variable];
if(!editor) {
throw tr("variable is read only");
}
const handleEditResult = result => {
if(typeof result === "undefined") {
/* change succeeded, no need to notify any variable since the consumer already has the newest value */
} else if(result === true || result === false) {
/* The new variable has been accepted/rejected and the variable should be updated on the remote side. */
/* TODO: Use cached value if the result is `false` */
this.sendVariable(variable, customData, true);
} else {
/* The new value hasn't been accepted. Instead a new value has been returned. */
this.doSendVariable(variable, customData, result);
}
}
const handleEditError = error => {
console.error("Failed to change variable %s: %o", variable, error);
this.sendVariable(variable, customData, true);
}
try {
let result = editor(newValue, customData);
if(result instanceof Promise) {
return result.then(handleEditResult).catch(handleEditError);
} else {
handleEditResult(result);
}
} catch (error) {
handleEditError(error);
}
}
protected abstract doSendVariable(variable: string, customData: any, value: any);
}
2021-01-21 16:54:55 +00:00
export type UiVariableStatus<Variables extends UiVariableMap, T extends keyof Variables> = {
2021-01-17 22:11:21 +00:00
status: "loading",
2021-01-21 16:54:55 +00:00
localValue: Variables[T] | undefined,
remoteValue: undefined,
/* Will do nothing */
setValue: (newValue: Variables[T], localOnly?: boolean) => void
2021-01-17 22:11:21 +00:00
} | {
status: "loaded" | "applying",
2021-01-21 16:54:55 +00:00
localValue: Variables[T],
remoteValue: Variables[T],
2021-01-17 22:11:21 +00:00
2021-01-21 16:54:55 +00:00
setValue: (newValue: Variables[T], localOnly?: boolean) => void
};
export type UiReadOnlyVariableStatus<Variables extends UiVariableMap, T extends keyof Variables> = {
status: "loading" | "loaded",
value: Variables[T],
2021-01-17 22:11:21 +00:00
};
type UiVariableCacheEntry = {
2021-01-21 16:54:55 +00:00
key: string,
2021-01-17 22:11:21 +00:00
useCount: number,
customData: any | undefined,
currentValue: any | undefined,
status: "loading" | "loaded" | "applying",
2021-01-21 16:54:55 +00:00
updateListener: ((clearLocalValue: boolean) => void)[]
}
type LocalVariableValue = {
status: "set" | "default",
value: any
} | {
status: "unset"
2021-01-17 22:11:21 +00:00
}
let staticRevisionId = 0;
export abstract class UiVariableConsumer<Variables extends UiVariableMap> {
private variableCache: {[key: string]: UiVariableCacheEntry[]} = {};
destroy() {
this.variableCache = {};
}
2021-01-21 16:54:55 +00:00
private getOrCreateVariable<T extends keyof Variables>(
variable: string,
2021-01-17 22:11:21 +00:00
customData?: any
2021-01-21 16:54:55 +00:00
) : UiVariableCacheEntry {
let cacheEntry = this.variableCache[variable]?.find(variable => _.isEqual(variable.customData, customData));
2021-01-17 22:11:21 +00:00
if(!cacheEntry) {
2021-01-21 16:54:55 +00:00
this.variableCache[variable] = this.variableCache[variable] || [];
this.variableCache[variable].push(cacheEntry = {
key: variable,
2021-01-17 22:11:21 +00:00
customData,
currentValue: undefined,
status: "loading",
useCount: 0,
updateListener: []
});
/* Might already call notifyRemoteVariable */
2021-01-21 16:54:55 +00:00
this.doRequestVariable(variable, customData);
}
return cacheEntry;
}
private derefVariable(variable: UiVariableCacheEntry) {
if(--variable.useCount === 0) {
const cache = this.variableCache[variable.key];
if(!cache) {
return;
}
cache.remove(variable);
if(cache.length === 0) {
delete this.variableCache[variable.key];
}
2021-01-17 22:11:21 +00:00
}
2021-01-21 16:54:55 +00:00
}
useVariable<T extends keyof WriteableVariables<Variables>>(
variable: T,
customData?: any,
defaultValue?: Variables[T]
) : UiVariableStatus<Variables, T> {
const haveDefaultValue = arguments.length >= 3;
const cacheEntry = this.getOrCreateVariable(variable as string, customData);
2021-01-17 22:11:21 +00:00
2021-03-13 19:10:00 +00:00
const [ localValue, setLocalValue ] = useDependentState<LocalVariableValue>(() => {
2021-01-17 22:11:21 +00:00
/* Variable constructor */
cacheEntry.useCount++;
2021-01-21 16:54:55 +00:00
2021-02-19 22:05:48 +00:00
if(cacheEntry.status === "loaded") {
2021-01-21 16:54:55 +00:00
return {
status: "set",
value: cacheEntry.currentValue
};
} else if(haveDefaultValue) {
return {
status: "default",
value: defaultValue
};
} else {
return {
status: "unset"
};
}
2021-03-13 19:10:00 +00:00
}, [ variable, customData ]);
2021-01-17 22:11:21 +00:00
const [, setRemoteVersion ] = useState(0);
useEffect(() => {
/* Initial rendered */
2021-01-21 16:54:55 +00:00
if(cacheEntry.status === "loaded" && localValue.status !== "set") {
/* Update the local value to the current state */
2021-03-13 19:10:00 +00:00
setLocalValue({ status: "set", value: cacheEntry.currentValue });
2021-01-17 22:11:21 +00:00
}
let listener;
2021-01-21 16:54:55 +00:00
cacheEntry.updateListener.push(listener = clearLocalValue => {
if(clearLocalValue) {
setLocalValue({ status: "unset" });
2021-01-17 22:11:21 +00:00
}
/* We can't just increment the old one by one since this update listener may fires twice before rendering */
setRemoteVersion(++staticRevisionId);
});
return () => {
cacheEntry.updateListener.remove(listener);
2021-01-21 16:54:55 +00:00
this.derefVariable(cacheEntry);
2021-01-17 22:11:21 +00:00
};
2021-03-13 19:10:00 +00:00
}, [ variable, customData ]);
2021-01-17 22:11:21 +00:00
if(cacheEntry.status === "loading") {
return {
status: "loading",
2021-01-21 16:54:55 +00:00
localValue: localValue.status === "unset" ? undefined : localValue.value,
2021-01-17 22:11:21 +00:00
remoteValue: undefined,
setValue: () => {}
};
} else {
return {
status: cacheEntry.status,
2021-01-21 16:54:55 +00:00
localValue: localValue.status === "set" ? localValue.value : cacheEntry.currentValue,
2021-01-17 22:11:21 +00:00
remoteValue: cacheEntry.currentValue,
2021-01-21 16:54:55 +00:00
2021-01-17 22:11:21 +00:00
setValue: (newValue, localOnly) => {
if(!localOnly && !_.isEqual(cacheEntry.currentValue, newValue)) {
const editingFinished = (succeeded: boolean) => {
if(cacheEntry.status !== "applying") {
/* A new value has already been emitted */
return;
}
cacheEntry.status = "loaded";
2021-01-21 16:54:55 +00:00
cacheEntry.currentValue = succeeded ? newValue : cacheEntry.currentValue;
2021-01-17 22:11:21 +00:00
cacheEntry.updateListener.forEach(callback => callback(true));
};
cacheEntry.status = "applying";
const result = this.doEditVariable(variable as string, customData, newValue);
if(result instanceof Promise) {
2021-01-21 16:54:55 +00:00
result
.then(() => editingFinished(true))
.catch(async error => {
console.error("Failed to change variable %s: %o", variable, error);
editingFinished(false);
});
2021-01-17 22:11:21 +00:00
/* State has changed, enforce a rerender */
cacheEntry.updateListener.forEach(callback => callback(false));
} else {
editingFinished(true);
return;
}
}
2021-01-21 16:54:55 +00:00
if(localValue.status !== "set" || !_.isEqual(newValue, localValue.value)) {
setLocalValue({
status: "set",
value: newValue
});
2021-01-17 22:11:21 +00:00
}
}
2021-01-21 16:54:55 +00:00
};
2021-01-17 22:11:21 +00:00
}
}
2021-01-21 16:54:55 +00:00
useReadOnly<T extends keyof Variables>(
2021-01-17 22:11:21 +00:00
variable: T,
customData?: any,
2021-01-21 16:54:55 +00:00
defaultValue?: never
) : UiReadOnlyVariableStatus<Variables, T>;
useReadOnly<T extends keyof Variables>(
variable: T,
customData: any | undefined,
defaultValue: Variables[T]
) : Variables[T];
useReadOnly(variable, customData?, defaultValue?) {
const cacheEntry = this.getOrCreateVariable(variable as string, customData);
const [, setRemoteVersion ] = useState(0);
useEffect(() => {
/* Initial rendered */
2021-01-22 12:04:09 +00:00
cacheEntry.useCount++;
2021-01-21 16:54:55 +00:00
let listener;
cacheEntry.updateListener.push(listener = () => {
/* We can't just increment the old one by one since this update listener may fires twice before rendering */
setRemoteVersion(++staticRevisionId);
});
return () => {
cacheEntry.updateListener.remove(listener);
this.derefVariable(cacheEntry);
2021-01-17 22:11:21 +00:00
};
2021-03-13 19:10:00 +00:00
}, [ variable, customData ]);
2021-01-21 16:54:55 +00:00
if(arguments.length >= 3) {
return cacheEntry.status === "loaded" ? cacheEntry.currentValue : defaultValue;
2021-01-17 22:11:21 +00:00
} else {
return {
2021-01-21 16:54:55 +00:00
status: cacheEntry.status,
value: cacheEntry.currentValue
};
2021-01-17 22:11:21 +00:00
}
}
protected notifyRemoteVariable(variable: string, customData: any | undefined, value: any) {
let cacheEntry = this.variableCache[variable]?.find(variable => _.isEqual(variable.customData, customData));
if(!cacheEntry) {
return;
}
cacheEntry.status = "loaded";
cacheEntry.currentValue = value;
cacheEntry.updateListener.forEach(callback => callback(true));
}
protected abstract doRequestVariable(variable: string, customData: any | undefined);
protected abstract doEditVariable(variable: string, customData: any | undefined, value: any) : Promise<void> | void;
}