UNPKG

@serenity-is/corelib

Version:
330 lines (289 loc) 11.8 kB
import { blockUI, blockUndo } from "./blockui"; import { htmlEncode } from "./html"; import { Lookup } from "./lookup"; import { notifyError } from "./notify"; import { PropertyItemsData } from "./propertyitem"; import { requestFinished, requestStarting, resolveUrl } from "./services"; import { scriptDataHashSymbol, scriptDataSymbol } from "./symbols"; import { getGlobalObject, isPromiseLike } from "./system"; /** * Gets the known hash value for a given dynamic script name. They are usually * registered server-side via dynamic script manager and their latest known * hashes are passed to the client-side via a script element named RegisteredScripts. * @param name The dynamic script name * @param reload True to force resetting the script hash client side, e.g. for loading * lookups etc. * @returns The hash or null if no such known registration */ export function getScriptDataHash(name: string, reload?: boolean): string { let json: string; let scriptDataHash = getGlobalObject()[scriptDataHashSymbol]; if (!scriptDataHash && typeof document !== "undefined" && (json = (document.querySelector('script#RegisteredScripts')?.innerHTML ?? '').trim()) && json.charAt(0) == '{') { var regs = JSON.parse(json); getGlobalObject()[scriptDataHashSymbol] = scriptDataHash = {}; for (var i in regs) { scriptDataHash[i] = regs[i]; } } if (!scriptDataHash) { if (reload) getGlobalObject()[scriptDataHashSymbol] = scriptDataHash = {}; else return null; } if (reload) return (scriptDataHash[name] = new Date().getTime().toString()); return scriptDataHash[name]; } /** * Hook for script data related operations */ export const scriptDataHooks = { /** * Provides a hook to override the default fetchScriptData implementation, * it falls back to the default implementation if undefined is returned. * It is recommended to use this hook mainly for test purposes. * If the sync parameter is true (legacy/compat), then the result should be returned synchronously. * DynJS parameter is true if the script is requested to be loaded via a dynamic script, * and not a JSON request. This parameter is only true for the legacy/compat sync mode. */ fetchScriptData: void 0 as <TData>(name: string, sync?: boolean, dynJS?: boolean) => TData | Promise<TData> } let fetchPromises: { [key: string]: Promise<any> } = {} /** * Fetches a script data with given name via ~/DynamicData endpoint * @param name Dynamic script name * @returns A promise that will return data if successfull */ export function fetchScriptData<TData>(name: string): Promise<TData> { const hookResult = scriptDataHooks.fetchScriptData?.<TData>(name); if (hookResult != void 0) { return Promise.resolve(hookResult).then(data => { if (name?.startsWith("Lookup.") && (data as any)?.Items && !(data as any).items && !(data as any).itemById) return new Lookup((hookResult as any).Params, (hookResult as any).Items) as any; return data; }); } let key = name + '?' + (getScriptDataHash(name) ?? ''); var promise: Promise<TData> = fetchPromises[key]; if (promise != null) return promise; if (typeof fetch === "undefined") return Promise.reject("The fetch method is not available!"); return fetchPromises[key] = (async function () { try { blockUI(); try { let url = resolveUrl('~/DynamicData/') + name + '?v=' + (getScriptDataHash(name) ?? new Date().getTime()); requestStarting(); try { var response = await fetch(url, { method: 'GET', cache: "force-cache", headers: { "Accept": "application/json" } }); if (!response.ok) handleScriptDataError(name, response.status, response.statusText ?? ''); const data = await response.json(); if (name.startsWith("Lookup.")) return new Lookup(data.Params, data.Items); return data; } finally { requestFinished(); } } finally { blockUndo(); } } finally { delete fetchPromises[key]; } })(); } /** * Returns the script data from cache if available, or via a fetch * request to ~/DynamicData endpoint * @param name * @param reload Clear cache and force reload * @returns */ export async function getScriptData<TData = any>(name: string, reload?: boolean): Promise<TData> { let data: any; if (reload) { getScriptDataHash(name, true); setScriptData(name, undefined); } else if ((data = peekScriptData(name)) != null) return data; data = await fetchScriptData<TData>(name); setScriptData(name, data); return data; } /** * Synchronous version of getScriptData for compatibility. Avoid this one where possible, * as it will block the UI thread. * @param name * @param dynJS * @returns */ export function ensureScriptDataSync<TData = any>(name: string, dynJS?: boolean): TData { var data = peekScriptData(name); if (data != null) return data; data = scriptDataHooks.fetchScriptData?.<TData>(name, true, dynJS); if (data !== void 0) { if (isPromiseLike(data)) throw new Error("fetchScriptData hook must return data synchronously when sync is true."); if (name.startsWith("Lookup.") && (data as any)?.Items) data = new Lookup((data as any).Params, (data as any).Items) as any; setScriptData(name, data); return data; } var url = resolveUrl(dynJS ? '~/DynJS.axd/' : '~/DynamicData/') + name + (dynJS ? '.js' : '') + '?v=' + (getScriptDataHash(name) ?? new Date().getTime()); var xhr = new XMLHttpRequest(); xhr.open("GET", url, false); requestStarting(); try { xhr.send(null); if (xhr.status !== 200) handleScriptDataError(name, xhr.status, xhr.statusText); if (dynJS) { var script = document.createElement("script"); script.text = xhr.responseText; document.head.appendChild(script).parentNode.removeChild(script); data = peekScriptData(name); } else { data = JSON.parse(xhr.responseText); } if (data == null) handleScriptDataError(name); if (!dynJS && name.startsWith("Lookup.")) data = new Lookup(data.Params, data.Items); setScriptData(name, data); return data; } finally { requestFinished(); } } /** * Gets or loads a [ColumnsScript] data * @param key Form key * @returns A property items data object containing items and additionalItems properties */ export function getColumnsScript(key: string): Promise<PropertyItemsData> { return getScriptData<PropertyItemsData>('Columns.' + key); } /** * Gets or loads a [FormScript] data * @param key Form key * @returns A property items data object containing items and additionalItems properties */ export function getFormScript(key: string): Promise<PropertyItemsData> { return getScriptData<PropertyItemsData>('Form.' + key); } /** * Gets or loads a Lookup * @param key Lookup key */ export function getLookupAsync<TItem>(key: string): Promise<Lookup<TItem>> { return getScriptData<Lookup<TItem>>('Lookup.' + key); } /** * Gets or loads a [RemoteData] * @param key Remote data key */ export function getRemoteDataAsync<TData = any>(key: string): Promise<TData> { return getScriptData<TData>('RemoteData.' + key); } /** * Synchronous version of getRemoteDataAsync for compatibility * @param key Remote data key */ export function getRemoteData<TData = any>(key: string): TData { return ensureScriptDataSync('RemoteData.' + key); } /** * Shows a suitable error message for errors occured during loading of * a dynamic script data. * @param name Name of the dynamic script * @param status HTTP status returned if available * @param statusText HTTP status text returned if available */ export function handleScriptDataError(name: string, status?: number, statusText?: string, shouldThrow = true): string { var isLookup = name?.startsWith("Lookup."); var message: string; if ((status == null && statusText == null) || (status === 404)) { if (isLookup) message = 'No lookup with key "' + name.substring(7) + '" is registered. Please make sure you have a' + ' [LookupScript("' + name.substring(7) + '")] attribute in server side code on top of a row / custom lookup and' + ' its key is exactly the same.'; else message = `Cannot load dynamic data: ${name}!`; notifyError(message); } else if (status == 403 && isLookup) { message = 'Access denied while trying to load the lookup: "' + htmlEncode(name.substring(7)) + '". Please check if current user has required permissions for this lookup.\n\n' + 'Lookups use the ReadPermission of their row by default. You may override that for the lookup ' + 'like [LookupScript("Some.Lookup", Permission = "?")] to grant all ' + 'authenticated users to read it (or use "*" for public).\n\n' + 'Note that this might be a security risk if the lookup contains sensitive data, ' + 'so it could be better to set a separate permission for lookups, like "MyModule:Lookups".'; notifyError(message, null, { timeOut: 10000, }); } else { message = "An error occurred while trying to load" + (isLookup ? ' the lookup: "' + name.substring(7) : ' dynamic data: "' + name) + '"!. Please check the error message displayed in the console for more info.'; notifyError(message); if (!status) console.log("An unknown connection error occurred!"); else if (status == 500) console.log("HTTP 500: Connection refused!"); else console.log("HTTP " + status + ': ' + statusText); } if (shouldThrow) throw message; return message; } export function peekScriptData(name: string): any { return getGlobalObject()[scriptDataSymbol]?.[name]; } /** * Forces reload of a lookup from the server. Note that only the * client side cache is cleared. This does not force reloading in the server-side. * @param key Lookup key * @returns Lookup */ export async function reloadLookupAsync<TItem = any>(key: string): Promise<Lookup<TItem>> { return await getScriptData('Lookup.' + key, true); } export function setRegisteredScripts(scripts: Record<string, string>) { var t = new Date().getTime().toString(); var scriptDataHash = getGlobalObject()[scriptDataHashSymbol]; if (!scriptDataHash) getGlobalObject()[scriptDataHashSymbol] = scriptDataHash = {}; for (var k in scripts) { scriptDataHash[k] = scripts[k] || t; } } export function setScriptData(name: string, value: any) { let scriptDataStore = getGlobalObject()[scriptDataSymbol]; if (!scriptDataStore) getGlobalObject()[scriptDataSymbol] = scriptDataStore = {}; scriptDataStore[name] = value; typeof document !== "undefined" && document.dispatchEvent?.(new Event("scriptdatachange." + name)); }