import { deepClone, isJsonEqual, tap } from "@milaboratories/helpers"; import type { Mutable } from "@milaboratories/helpers"; import type { NavigationState, BlockOutputsBase, BlockState, PlatformaV1, PlatformaExtended, } from "@platforma-sdk/model"; import { reactive, nextTick, computed, watch } from "vue"; import type { StateModelOptions, UnwrapOutputs, OptionalResult, OutputValues, OutputErrors, AppSettings, } from "../types"; import { createModel } from "../createModel"; import { createAppModel } from "./createAppModel"; import { parseQuery } from "../urls"; import { ensureOutputHasStableFlag, MultiError, unwrapOutput } from "../utils"; import { useDebounceFn } from "@vueuse/core"; /** * Creates an application instance with reactive state management, outputs, and methods for state updates and navigation. * * @template Args - The type of arguments used in the application. * @template Outputs - The type of block outputs extending `BlockOutputsBase`. * @template UiState - The type of the UI state. * @template Href - The type of navigation href, defaulting to a string starting with `/`. * * @param state - Initial state of the application, including args, outputs, UI state, and navigation state. * @param platforma - A platform interface for interacting with block states. * @param settings - Application settings, such as debug flags. * * @returns A reactive application object with methods, getters, and state. */ export function createAppV1< Args = unknown, Outputs extends BlockOutputsBase = BlockOutputsBase, UiState = unknown, Href extends `/${string}` = `/${string}`, >( state: BlockState, platforma: PlatformaExtended>, settings: AppSettings, ) { type AppModel = { args: Args; ui: UiState; }; const log = (msg: string, ...rest: unknown[]) => { if (settings.debug) { console.log(`%c>>> %c${msg}`, "color: orange; font-weight: bold", "color: orange", ...rest); } }; /** * Reactive snapshot of the application state, including args, outputs, UI state, and navigation state. */ const snapshot = reactive({ args: Object.freeze(state.args), outputs: Object.freeze(state.outputs), ui: Object.freeze(state.ui), navigationState: Object.freeze(state.navigationState) as NavigationState, }) as { args: Readonly; outputs: Partial>; ui: Readonly; navigationState: Readonly>; }; const debounceSpan = settings.debounceSpan ?? 200; const maxWait = tap(settings.debounceMaxWait ?? 0, (v) => v < 20_000 ? 20_000 : v < debounceSpan ? debounceSpan * 100 : v, ); const setBlockArgs = useDebounceFn( (args: Args) => { if (!isJsonEqual(args, snapshot.args)) { platforma.setBlockArgs(args); } }, debounceSpan, { maxWait }, ); const setBlockUiState = useDebounceFn( (ui: UiState) => { if (!isJsonEqual(ui, snapshot.ui)) { platforma.setBlockUiState(ui); } }, debounceSpan, { maxWait }, ); const setBlockArgsAndUiState = useDebounceFn( (args: Args, ui: UiState) => { if (!isJsonEqual(args, snapshot.args) || !isJsonEqual(ui, snapshot.ui)) { platforma.setBlockArgsAndUiState(args, ui); } }, debounceSpan, { maxWait }, ); (platforma as PlatformaV1).onStateUpdates(async (updates) => { updates.forEach((patch) => { if (patch.key === "args" && !isJsonEqual(snapshot.args, patch.value)) { snapshot.args = Object.freeze(patch.value); log("args patch", snapshot.args); } if (patch.key === "ui" && !isJsonEqual(snapshot.ui, patch.value)) { snapshot.ui = Object.freeze(patch.value); log("ui patch", snapshot.ui); } if (patch.key === "outputs" && !isJsonEqual(snapshot.outputs, patch.value)) { snapshot.outputs = Object.freeze(patch.value); log("outputs patch", snapshot.outputs); } if (patch.key === "navigationState" && !isJsonEqual(snapshot.navigationState, patch.value)) { snapshot.navigationState = Object.freeze(patch.value); log("navigationState patch", snapshot.navigationState); } }); await nextTick(); }); const cloneArgs = () => deepClone(snapshot.args) as Args; const cloneUiState = () => deepClone(snapshot.ui) as UiState; const cloneNavigationState = () => deepClone(snapshot.navigationState) as Mutable>; const methods = { createArgsModel(options: StateModelOptions = {}) { return createModel({ get() { if (options.transform) { return options.transform(snapshot.args); } return snapshot.args as T; }, validate: options.validate, autoSave: true, onSave(newArgs) { setBlockArgs(newArgs); }, }); }, /** * defaultUiState is temporarily here, remove it after implementing initialUiState */ createUiModel( options: StateModelOptions = {}, defaultUiState: () => UiState, ) { return createModel({ get() { if (options.transform) { return options.transform(snapshot.ui); } return (snapshot.ui ?? defaultUiState()) as T; }, validate: options.validate, autoSave: true, onSave(newData) { setBlockUiState(newData); }, }); }, /** * Note: Don't forget to list the output names, like: useOutputs('output1', 'output2', ...etc) * @param keys - List of output names * @returns {OptionalResult>} */ useOutputs(...keys: K[]): OptionalResult> { const data = reactive({ errors: undefined, value: undefined, }); watch( () => snapshot.outputs, () => { try { Object.assign(data, { value: this.unwrapOutputs(...keys), errors: undefined, }); } catch (error) { Object.assign(data, { value: undefined, errors: [String(error)], }); } }, { immediate: true, deep: true }, ); return data as OptionalResult>; }, /** * Retrieves the unwrapped values of outputs for the given keys. * * @template K - Keys of the outputs to unwrap. * @param keys - List of output names. * @throws Error if the outputs contain errors. * @returns An object with unwrapped output values. */ unwrapOutputs(...keys: K[]): UnwrapOutputs { const outputs = snapshot.outputs; const entries = keys.map((key) => [key, unwrapOutput(outputs[key])]); return Object.fromEntries(entries); }, /** * Updates the arguments state by applying a callback. * * @param cb - Callback to modify the current arguments. * @returns A promise resolving after the update is applied. */ updateArgs(cb: (args: Args) => void) { const newArgs = cloneArgs(); cb(newArgs); return platforma.setBlockArgs(newArgs); }, /** * Updates the UI state by applying a callback. * * @param cb - Callback to modify the current UI state. * @returns A promise resolving after the update is applied. * @todo Make it mutable since there is already an initial one */ updateUiState(cb: (args: UiState) => UiState): Promise { const newUiState = cloneUiState(); return platforma.setBlockUiState(cb(newUiState)); }, /** * Updates the navigation state by applying a callback. * * @param cb - Callback to modify the current navigation state. * @returns A promise resolving after the update is applied. */ updateNavigationState(cb: (args: Mutable>) => void) { const newState = cloneNavigationState(); cb(newState); return platforma.setNavigationState(newState); }, /** * Navigates to a specific href by updating the navigation state. * * @param href - The target href to navigate to. * @returns A promise resolving after the navigation state is updated. */ navigateTo(href: Href) { const newState = cloneNavigationState(); newState.href = href; return platforma.setNavigationState(newState); }, }; const outputs = computed>(() => { const entries = Object.entries(snapshot.outputs).map(([k, outputWithStatus]) => platforma.blockModelInfo.outputs[k].withStatus ? [k, ensureOutputHasStableFlag(outputWithStatus)] : [ k, outputWithStatus.ok && outputWithStatus.value !== undefined ? outputWithStatus.value : undefined, ], ); return Object.fromEntries(entries); }); const outputErrors = computed>(() => { const entries = Object.entries(snapshot.outputs).map(([k, vOrErr]) => [ k, vOrErr && vOrErr.ok === false ? new MultiError(vOrErr.errors) : undefined, ]); return Object.fromEntries(entries); }); const getters = { snapshot, queryParams: computed(() => parseQuery(snapshot.navigationState.href)), href: computed(() => snapshot.navigationState.href), hasErrors: computed(() => Object.values(snapshot.outputs).some((v) => !v?.ok)), }; const model = createAppModel( { get() { return { args: snapshot.args, ui: snapshot.ui } as AppModel; }, autoSave: true, onSave(newData: AppModel) { setBlockArgsAndUiState(newData.args, newData.ui); }, }, { outputs, outputErrors, }, settings, ); return reactive(Object.assign(model, methods, getters)); } export type BaseAppV1< Args = unknown, Outputs extends BlockOutputsBase = BlockOutputsBase, UiState = unknown, Href extends `/${string}` = `/${string}`, > = ReturnType>;