All files / blong-browser/src/model dropdownRegistry.ts

83.16% Statements 84/101
80% Branches 4/5
33.33% Functions 2/6
83.16% Lines 84/101

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 1021x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x         1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x                           1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x  
/**
 * Dropdown registry — on-demand load and cache of named dropdown lists.
 *
 * Widgets declare their dropdown dependency by name:
 *   widget: {type: 'dropdown', dropdown: 'marine.species'}
 *
 * When a component needs the data, it calls:
 *   dropdownRegistry.get('marine.species', loader)
 *
 * The loader is responsible for calling the backend (e.g. via useHandler).
 * The registry deduplicates concurrent requests and caches the result.
 *
 * The naming convention for the backend handler is:
 *   {subject}.dropdown.list({name: 'marine.species'}) → [{value, label}]
 */
import type {IDropdownOption} from '@feasibleone/blong';

type LoaderFn = (name: string) => Promise<IDropdownOption[]>;
type BatchLoaderFn = (names: string[]) => Promise<Record<string, IDropdownOption[]>>;
 
class DropdownRegistry {
    private cache = new Map<string, IDropdownOption[]>();
    private pending = new Map<string, Promise<IDropdownOption[]>>();
 
    /**
     * Get a dropdown by name, loading it via the provided loader if not cached.
     *
     * @param name - Dropdown key, e.g. 'marine.species'
     * @param loader - Function that fetches the dropdown data from the backend
     */
    async get(name: string, loader: LoaderFn): Promise<IDropdownOption[]> {
        if (this.cache.has(name)) return this.cache.get(name)!;

        if (!this.pending.has(name)) {
            const promise = loader(name)
                .then(data => {
                    this.cache.set(name, data);
                    this.pending.delete(name);
                    return data;
                })
                .catch(err => {
                    this.pending.delete(name);
                    // Re-throw so callers (e.g. DropdownWidget) can handle the error
                    // (show a dialog, log the user out, etc.).
                    throw err;
                });
            this.pending.set(name, promise);
        }
 
        return this.pending.get(name)!;
    }
 
    /**
     * Pre-populate the cache without async loading (used by mocks and preload).
     */
    set(name: string, data: IDropdownOption[]): void {
        this.cache.set(name, data);
    }
 
    /**
     * Batch-preload multiple dropdowns.  Names already cached are skipped.
     *
     * @param names - Dropdown names to load
     * @param loader - Batch loader that returns a map of name → options
     */
    preload(names: string[], loader: BatchLoaderFn): Promise<void> {
        const missing = names.filter(n => !this.cache.has(n) && !this.pending.has(n));
        if (!missing.length) return Promise.resolve();
 
        const promise = loader(missing).then(results => {
            for (const [name, data] of Object.entries(results)) {
                this.cache.set(name, data);
                this.pending.delete(name);
            }
        });
 
        // Mark all as pending immediately to avoid duplicate requests
        for (const name of missing) {
            this.pending.set(
                name,
                promise.then(() => this.cache.get(name) ?? []),
            );
        }
 
        return promise.then(() => undefined);
    }
 
    /** Return true if the named dropdown is already loaded */
    has(name: string): boolean {
        return this.cache.has(name);
    }
 
    /** Clear the cache (useful in tests) */
    clear(): void {
        this.cache.clear();
        this.pending.clear();
    }
}
 
/** Singleton dropdown registry — shared across all components */
export const dropdownRegistry = new DropdownRegistry();