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

0% Statements 0/101
0% Branches 0/1
0% Functions 0/1
0% Lines 0/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 102                                                                                                                                                                                                           
/**
 * 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();