diff --git a/components/check-permissions/checkPermissions.js b/components/check-permissions/checkPermissions.js index 56f00575..c7469f3a 100644 --- a/components/check-permissions/checkPermissions.js +++ b/components/check-permissions/checkPermissions.js @@ -17,13 +17,14 @@ import TPEN from '../../api/TPEN.js' import { getUserFromToken } from '../../components/iiif-tools/index.js' import { permissionMatch } from "../../components/check-permissions/permission-match.js" +import { whenProjectReady } from "../../utilities/projectReady.js" class checkPermissions { #project #userId constructor() { - TPEN.eventDispatcher.on('tpen-project-loaded', (ev) => { + whenProjectReady((ev) => { this.#project = ev.detail this.#userId = getUserFromToken(TPEN.getAuthorization()) }) diff --git a/components/check-permissions/permission-match.js b/components/check-permissions/permission-match.js index cf967c4b..037dc26f 100644 --- a/components/check-permissions/permission-match.js +++ b/components/check-permissions/permission-match.js @@ -26,6 +26,7 @@ import TPEN from '../../api/TPEN.js' import { getUserFromToken } from "../../components/iiif-tools/index.js" +import { whenProjectReady } from "../../utilities/projectReady.js" // TODO use these from a central location, such as a Permission Class. const ENTITIES = [ "PROJECT", @@ -39,7 +40,10 @@ const ENTITIES = [ "*", "ANY" ] -TPEN.eventDispatcher.on("tpen-project-loaded", ev => checkElements(ev.detail)) +// Defer one microtask so any tpen-view/tpen-edit elements added by +// sibling modules during their own evaluation are present in the DOM +// before checkElements scans for them. +queueMicrotask(() => whenProjectReady(ev => checkElements(ev.detail))) /** * Gather all elements with the tpen-view or tpen-edit attributes. diff --git a/components/update-metadata/index.html b/components/update-metadata/index.html index 6dc7455a..304b70de 100644 --- a/components/update-metadata/index.html +++ b/components/update-metadata/index.html @@ -40,9 +40,9 @@

Edit Metadata

diff --git a/interfaces/manage-project/index.html b/interfaces/manage-project/index.html index a482d9a8..fffb0615 100644 --- a/interfaces/manage-project/index.html +++ b/interfaces/manage-project/index.html @@ -81,9 +81,9 @@

Export & Links

diff --git a/interfaces/project/metadata.html b/interfaces/project/metadata.html index 89e9e751..61afd74e 100644 --- a/interfaces/project/metadata.html +++ b/interfaces/project/metadata.html @@ -89,10 +89,10 @@

Edit Project Metadata

Go to Project Management diff --git a/utilities/__tests__/projectReady.test.js b/utilities/__tests__/projectReady.test.js index 3851a0c4..208d891f 100644 --- a/utilities/__tests__/projectReady.test.js +++ b/utilities/__tests__/projectReady.test.js @@ -23,7 +23,7 @@ const mockDispatcher = { const { default: TPEN } = await import('../../api/TPEN.js') TPEN.eventDispatcher = mockDispatcher -const { onProjectReady } = await import('../projectReady.js') +const { onProjectReady, whenProjectReady } = await import('../projectReady.js') describe('onProjectReady', () => { afterEach(() => { @@ -63,3 +63,66 @@ describe('onProjectReady', () => { }) }) }) + +describe('whenProjectReady', () => { + afterEach(() => { + mockDispatcher._handlers.clear() + TPEN.activeProject = undefined + }) + + it('subscribes handler to tpen-project-loaded event', () => { + let fired = 0 + whenProjectReady(() => { fired++ }) + mockDispatcher.dispatch('tpen-project-loaded', { _id: 'p1' }) + assert.equal(fired, 1) + }) + + it('invokes synchronously with synthetic event when project already loaded', () => { + const project = { _createdAt: Date.now(), _id: 'p1' } + TPEN.activeProject = project + let received + whenProjectReady(ev => { received = ev }) + assert.deepEqual(received, { detail: project }) + }) + + it('does not invoke synchronously when project is not loaded', () => { + let fired = 0 + whenProjectReady(() => { fired++ }) + assert.equal(fired, 0) + }) + + it('returns an unsubscribe function', () => { + let fired = 0 + const unsub = whenProjectReady(() => { fired++ }) + unsub() + mockDispatcher.dispatch('tpen-project-loaded', { _id: 'p1' }) + assert.equal(fired, 0) + }) + + it('returns a no-op when handler is missing', () => { + assert.doesNotThrow(() => { whenProjectReady(null)?.() }) + }) + + it('does not also receive future dispatches after a sync fire', () => { + TPEN.activeProject = { _createdAt: Date.now(), _id: 'p1' } + let fired = 0 + whenProjectReady(() => { fired++ }) + mockDispatcher.dispatch('tpen-project-loaded', { _id: 'p2' }) + assert.equal(fired, 1) + }) + + it('logs and recovers when sync handler throws', () => { + TPEN.activeProject = { _createdAt: Date.now(), _id: 'p1' } + const originalError = console.error + let logged + console.error = (...args) => { logged = args } + try { + assert.doesNotThrow(() => { + whenProjectReady(() => { throw new Error('boom') }) + }) + assert.ok(logged?.[0]?.includes('[whenProjectReady]')) + } finally { + console.error = originalError + } + }) +}) diff --git a/utilities/projectReady.js b/utilities/projectReady.js index f049086f..44cebf66 100644 --- a/utilities/projectReady.js +++ b/utilities/projectReady.js @@ -1,13 +1,55 @@ import TPEN from "../api/TPEN.js" +/** + * Bind `handler` to `ctx` and invoke it when the active project is ready. + * Intended for component lifecycle use where the handler needs a `this` + * context (typically a custom element). If the project is already loaded + * when called, the bound handler is invoked synchronously and no listener + * is registered. Otherwise, subscribes for the next `tpen-project-loaded` + * dispatch. The sync path and the listener are mutually exclusive — the + * handler is invoked exactly once for the load. + * @param {object} ctx - Object to bind the handler to (e.g. a component instance). + * @param {(this: object, ev?: { detail: any }) => void} handler + * @param {string} [eventName='tpen-project-loaded'] + * @returns {() => void} unsubscribe (no-op when invoked synchronously or when ctx/handler is missing) + */ export const onProjectReady = (ctx, handler, eventName = 'tpen-project-loaded') => { if (!ctx || typeof handler !== 'function') return () => {} const bound = handler.bind(ctx) - try { - if (TPEN.activeProject?._createdAt) { + if (TPEN.activeProject?._createdAt) { + try { bound() + } catch (err) { + console.error('[onProjectReady] handler threw during sync invocation:', err) } - } catch (_) {} + return () => {} + } TPEN.eventDispatcher.on(eventName, bound) return () => TPEN.eventDispatcher.off(eventName, bound) } + +/** + * Context-free variant of {@link onProjectReady} for inline `