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;
|
|
|
|
}
|