From 0202d3831ee0baed60e357c9c6f346ed7d18b151 Mon Sep 17 00:00:00 2001 From: Lamont Crook Date: Tue, 16 Jun 2026 17:04:33 -0600 Subject: [PATCH 01/11] Add status filters and reviewed/engaged actions to queue Adds a Spectrum-token filter bar to the Pilot Applications queue with Incomplete / Complete / Reviewed / Engaged toggles (live counts, URL-persisted, status group OR + flag AND combine logic). Complete means submitted but not yet reviewed or engaged. Cards gain status badges, a date-submitted line, and per-card Reviewed/Engaged toggles persisted through Fusion ({ review } / { engage }). CSV export and the count line respect the active filter. Co-Authored-By: Claude Opus 4.8 --- tools/queue/queue.css | 217 ++++++++++++++++++++++ tools/queue/queue.js | 422 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 629 insertions(+), 10 deletions(-) diff --git a/tools/queue/queue.css b/tools/queue/queue.css index 84055ef..f66113e 100644 --- a/tools/queue/queue.css +++ b/tools/queue/queue.css @@ -9,9 +9,15 @@ ema-queue { --spectrum-gray-500: #909090; --spectrum-gray-700: #464646; --spectrum-gray-900: #1e1e1e; + --spectrum-red-100: #fbe6e5; --spectrum-red-800: #d31510; + --spectrum-blue-100: #e6f0ff; --spectrum-blue-800: #0265dc; --spectrum-blue-900: #0054b6; + --spectrum-green-100: #e9f7ec; + --spectrum-green-800: #0a7c3e; + --spectrum-orange-100: #fdf0e1; + --spectrum-orange-800: #b35c00; --spectrum-font-family: adobe-clean, 'Source Sans 3', 'Source Sans Pro', var(--spectrum-font-family-base, system-ui), sans-serif; --spectrum-font-size-100: 14px; @@ -152,6 +158,21 @@ ema-queue *::after { background: var(--spectrum-red-800); } +.ema-queue__badge--complete { + color: var(--spectrum-green-800); + background: var(--spectrum-green-100); +} + +.ema-queue__badge--reviewed { + color: var(--spectrum-blue-900); + background: var(--spectrum-blue-100); +} + +.ema-queue__badge--engaged { + color: var(--spectrum-orange-800); + background: var(--spectrum-orange-100); +} + .ema-queue__card-header { display: flex; align-items: flex-start; @@ -434,3 +455,199 @@ body.ema-queue-loading { gap: 2px; } } + +.ema-queue__filters { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 16px; + padding: 10px 0; + border-top: 1px solid var(--spectrum-gray-200); + border-bottom: 1px solid var(--spectrum-gray-200); +} + +.ema-queue__filters-label { + margin-right: 2px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--spectrum-gray-500); +} + +.ema-queue__filter { + display: inline-flex; + align-items: center; + gap: 7px; + margin: 0; + padding: 5px 12px; + border: 1px solid var(--spectrum-gray-300); + border-radius: 999px; + background: var(--spectrum-gray-50); + font: inherit; + font-size: 13px; + font-weight: 600; + color: var(--spectrum-gray-700); + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} + +.ema-queue__filter:hover { + background: var(--spectrum-gray-100); + border-color: var(--spectrum-gray-500); +} + +.ema-queue__filter:focus-visible { + outline: 2px solid var(--spectrum-blue-800); + outline-offset: 2px; +} + +.ema-queue__filter.is-selected { + color: var(--spectrum-gray-50); + background: var(--spectrum-gray-900); + border-color: var(--spectrum-gray-900); +} + +.ema-queue__filter--incomplete.is-selected { + background: var(--spectrum-red-800); + border-color: var(--spectrum-red-800); +} + +.ema-queue__filter--complete.is-selected { + background: var(--spectrum-green-800); + border-color: var(--spectrum-green-800); +} + +.ema-queue__filter--reviewed.is-selected { + background: var(--spectrum-blue-800); + border-color: var(--spectrum-blue-800); +} + +.ema-queue__filter--engaged.is-selected { + background: var(--spectrum-orange-800); + border-color: var(--spectrum-orange-800); +} + +.ema-queue__filter-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 18px; + padding: 0 6px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + color: var(--spectrum-gray-700); + background: var(--spectrum-gray-200); +} + +.ema-queue__filter.is-selected .ema-queue__filter-count { + color: var(--spectrum-gray-50); + background: rgb(255 255 255 / 25%); +} + +.ema-queue__filter-clear { + margin: 0; + padding: 5px 8px; + border: none; + background: none; + font: inherit; + font-size: 13px; + font-weight: 600; + color: var(--spectrum-blue-800); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.ema-queue__filter-clear:hover { + color: var(--spectrum-blue-900); +} + +.ema-queue__filter-clear:focus-visible { + outline: 2px solid var(--spectrum-blue-800); + outline-offset: 2px; + border-radius: 2px; +} + +.ema-queue__card-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--spectrum-gray-200); +} + +.ema-queue__toggle { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + padding: 5px 12px; + border: 1px solid var(--spectrum-gray-300); + border-radius: 999px; + background: var(--spectrum-gray-50); + font: inherit; + font-size: 13px; + font-weight: 500; + color: var(--spectrum-gray-700); + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} + +.ema-queue__toggle svg { + display: block; +} + +.ema-queue__toggle:hover:not(:disabled) { + background: var(--spectrum-gray-100); + border-color: var(--spectrum-gray-500); +} + +.ema-queue__toggle:focus-visible { + outline: 2px solid var(--spectrum-blue-800); + outline-offset: 2px; +} + +.ema-queue__toggle:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.ema-queue__toggle--reviewed.is-on { + color: var(--spectrum-blue-900); + background: var(--spectrum-blue-100); + border-color: rgb(2 101 220 / 35%); +} + +.ema-queue__toggle--reviewed.is-on:hover:not(:disabled) { + background: var(--spectrum-blue-100); + border-color: var(--spectrum-blue-800); +} + +.ema-queue__toggle--engaged.is-on { + color: var(--spectrum-orange-800); + background: var(--spectrum-orange-100); + border-color: rgb(179 92 0 / 35%); +} + +.ema-queue__toggle--engaged.is-on:hover:not(:disabled) { + background: var(--spectrum-orange-100); + border-color: var(--spectrum-orange-800); +} + +.ema-queue__card-date { + margin: 4px 0 0; + font-size: 12px; + color: var(--spectrum-gray-500); +} + +.ema-queue__card-date-label { + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; +} diff --git a/tools/queue/queue.js b/tools/queue/queue.js index f80870d..a692fc8 100644 --- a/tools/queue/queue.js +++ b/tools/queue/queue.js @@ -1,12 +1,16 @@ /** * DA-embedded queue tool: lists form shares via Fusion webhook. * - * POST list: { share: "all" } + * POST list: { share: "all" } * POST delete: { delete: "" } + * POST review: { review: "", reviewed: } + * POST engage: { engage: "", engaged: } * Endpoint: DA repo config → fusion sheet → key `endpoint` (see config/repo-config.example.json). * * Fusion list response: array of { json: "" } wrappers. - * Each record includes `key` — sent as `delete` when removing a share. + * Each record includes `key` and the status fields `reviewed`, `engaged`, and + * `date-submitted`. `reviewed`/`engaged` are persisted back through Fusion so the + * state is shared across all reviewers (not browser-local). */ import DA_SDK from 'https://da.live/nx/utils/sdk.js'; import { LitElement, html, nothing } from '../../deps/lit/dist/index.js'; @@ -112,6 +116,41 @@ function postShareDelete(endpoint, shareId) { return postFusion(endpoint, { delete: shareId }); } +/** + * Persist the reviewed flag for a share back through Fusion. + * @param {string} endpoint + * @param {string} shareId + * @param {boolean} reviewed + * @returns {Promise} + */ +function postShareReview(endpoint, shareId, reviewed) { + return postFusion(endpoint, { review: shareId, reviewed }); +} + +/** + * Persist the engaged flag for a share back through Fusion. + * @param {string} endpoint + * @param {string} shareId + * @param {boolean} engaged + * @returns {Promise} + */ +function postShareEngage(endpoint, shareId, engaged) { + return postFusion(endpoint, { engage: shareId, engaged }); +} + +/** + * @param {unknown} value + * @returns {boolean} + */ +function isTruthyFlag(value) { + if (value === true) return true; + if (typeof value === 'string') { + const s = value.trim().toLowerCase(); + return s === 'true' || s === 'yes'; + } + return false; +} + /** * @param {unknown} value * @returns {boolean} @@ -135,6 +174,118 @@ function isIncompleteSubmission(record) { return false; } +/** + * Reviewed is a human action persisted on the Fusion record as `reviewed`. + * @param {QueueCardRecord} record + * @returns {boolean} + */ +function isReviewed(record) { + return isTruthyFlag(record.reviewed); +} + +/** + * Engaged is a human action persisted on the Fusion record as `engaged`. + * Distinct from the `is-xsc-engaged` form answer, which stays a card chip. + * @param {QueueCardRecord} record + * @returns {boolean} + */ +function isEngaged(record) { + return isTruthyFlag(record.engaged); +} + +/** + * Format the persisted `date-submitted` value for display. Returns '' when absent + * or unparseable so the date line can be omitted. + * @param {QueueCardRecord} record + * @returns {string} + */ +function submittedDate(record) { + const raw = record['date-submitted'] ?? record.dateSubmitted; + if (!isPresent(raw)) return ''; + const str = String(raw).trim(); + let date; + if (/^\d{4}-\d{2}-\d{2}$/.test(str)) { + // Date-only: build in local time so it doesn't shift a day across time zones. + const [y, m, d] = str.split('-').map(Number); + date = new Date(y, m - 1, d); + } else { + const ms = typeof raw === 'number' ? raw : Date.parse(str); + if (Number.isNaN(ms)) return str; + date = new Date(ms); + } + return date.toLocaleDateString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', + }); +} + +/** + * "Complete" is the unprocessed bucket: submitted, but not yet reviewed or engaged. + * Reviewed and Engaged are the further-along states and own their cards instead. + * @param {QueueCardRecord} record + * @returns {boolean} + */ +function isCompleteSubmission(record) { + return !isIncompleteSubmission(record) && !isReviewed(record) && !isEngaged(record); +} + +/** + * Queue filters. `status` filters (Incomplete/Complete) combine as OR within the + * group; `flag` filters (Reviewed/Engaged) each add an AND constraint. + * @typedef {(record: QueueCardRecord) => boolean} FilterTest + * @type {{ key: string, label: string, group: 'status' | 'flag', test: FilterTest }[]} + */ +const FILTER_DEFS = [ + { key: 'incomplete', label: 'Incomplete', group: 'status', test: isIncompleteSubmission }, + { key: 'complete', label: 'Complete', group: 'status', test: isCompleteSubmission }, + { key: 'reviewed', label: 'Reviewed', group: 'flag', test: isReviewed }, + { key: 'engaged', label: 'Engaged', group: 'flag', test: isEngaged }, +]; + +const FILTER_KEYS = new Set(FILTER_DEFS.map((f) => f.key)); + +/** + * @param {QueueCardRecord} record + * @param {string[]} activeKeys + * @returns {boolean} + */ +function matchesFilters(record, activeKeys) { + if (!activeKeys.length) return true; + const active = FILTER_DEFS.filter((f) => activeKeys.includes(f.key)); + const statusActive = active.filter((f) => f.group === 'status'); + const flagActive = active.filter((f) => f.group === 'flag'); + if (statusActive.length && !statusActive.some((f) => f.test(record))) return false; + if (flagActive.length && !flagActive.every((f) => f.test(record))) return false; + return true; +} + +/** + * Read active filter keys from the URL `?filter=` param. + * @returns {string[]} + */ +function readFiltersFromUrl() { + try { + const raw = new URL(window.location.href).searchParams.get('filter') || ''; + return raw.split(',').map((s) => s.trim()).filter((k) => FILTER_KEYS.has(k)); + } catch { + return []; + } +} + +/** + * Reflect active filter keys into the URL for shareable views. + * @param {string[]} keys + */ +function writeFiltersToUrl(keys) { + try { + const url = new URL(window.location.href); + if (keys.length) url.searchParams.set('filter', keys.join(',')); + else url.searchParams.delete('filter'); + window.history.replaceState(null, '', url); + } catch { + /* history not available */ + } +} + /** * @param {Record} [record] * @returns {string} @@ -190,6 +341,7 @@ const META_FIELD_KEYS = new Set([ * queueIndex?: number; * recordIndex?: number; * parseError?: boolean; + * reviewed?: boolean | string; * }} QueueCardRecord */ @@ -352,6 +504,9 @@ class EmaQueue extends LitElement { _bootstrapped: { state: true }, _deletingShareId: { state: true }, _pendingDelete: { state: true }, + _reviewingShareId: { state: true }, + _engagingShareId: { state: true }, + _activeFilters: { state: true }, }; constructor() { @@ -367,6 +522,9 @@ class EmaQueue extends LitElement { this._bootstrapped = false; this._deletingShareId = null; this._pendingDelete = null; + this._reviewingShareId = null; + this._engagingShareId = null; + this._activeFilters = readFiltersFromUrl(); } createRenderRoot() { @@ -411,6 +569,42 @@ class EmaQueue extends LitElement { return normalizeFusionQueue(this._data); } + get _visibleCards() { + return this._cards.filter((record) => matchesFilters(record, this._activeFilters)); + } + + /** True while any delete/review/engage mutation is in flight or pending. */ + get _busy() { + return this._loading || Boolean(this._deletingShareId) || Boolean(this._pendingDelete) + || Boolean(this._reviewingShareId) || Boolean(this._engagingShareId); + } + + /** Count of all loaded cards matching each filter, for the pill badges. */ + get _filterCounts() { + const cards = this._cards; + return FILTER_DEFS.reduce((acc, f) => { + acc[f.key] = cards.filter(f.test).length; + return acc; + }, /** @type {Record} */ ({})); + } + + /** + * @param {string} key + */ + _toggleFilter(key) { + const next = new Set(this._activeFilters); + if (next.has(key)) next.delete(key); + else next.add(key); + this._activeFilters = FILTER_DEFS.map((f) => f.key).filter((k) => next.has(k)); + writeFiltersToUrl(this._activeFilters); + } + + _clearFilters() { + if (!this._activeFilters.length) return; + this._activeFilters = []; + writeFiltersToUrl(this._activeFilters); + } + _isExpanded(cardId) { return this._expandedKeys.includes(cardId); } @@ -457,7 +651,7 @@ class EmaQueue extends LitElement { */ _onDeleteClick(record) { const { shareId } = record; - if (this._loading || this._deletingShareId || this._pendingDelete) return; + if (this._busy) return; if (!shareId) { this._error = 'Cannot delete: no share key on this row. Fusion must return key, shareId, id, or share_id.'; this.requestUpdate(); @@ -508,11 +702,144 @@ class EmaQueue extends LitElement { } } + /** + * Toggle the reviewed flag and persist it through Fusion. + * @param {QueueCardRecord} record + */ + async _onToggleReviewed(record) { + const { shareId } = record; + if (this._busy) return; + if (!shareId) { + this._error = 'Cannot mark reviewed: no share key on this row.'; + this.requestUpdate(); + return; + } + const endpoint = this.fusionEndpoint; + if (!endpoint) { + this._error = 'Fusion endpoint not configured. Add fusion.endpoint in repo DA config.'; + this.requestUpdate(); + return; + } + const nextReviewed = !isReviewed(record); + this._reviewingShareId = shareId; + this._error = null; + this.requestUpdate(); + try { + await postShareReview(endpoint, shareId, nextReviewed); + await this.loadShares(); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Could not update reviewed state'; + this._error = msg; + } finally { + this._reviewingShareId = null; + this.requestUpdate(); + } + } + + /** + * Toggle the engaged flag and persist it through Fusion. + * @param {QueueCardRecord} record + */ + async _onToggleEngaged(record) { + const { shareId } = record; + if (this._busy) return; + if (!shareId) { + this._error = 'Cannot mark engaged: no share key on this row.'; + this.requestUpdate(); + return; + } + const endpoint = this.fusionEndpoint; + if (!endpoint) { + this._error = 'Fusion endpoint not configured. Add fusion.endpoint in repo DA config.'; + this.requestUpdate(); + return; + } + const nextEngaged = !isEngaged(record); + this._engagingShareId = shareId; + this._error = null; + this.requestUpdate(); + try { + await postShareEngage(endpoint, shareId, nextEngaged); + await this.loadShares(); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Could not update engaged state'; + this._error = msg; + } finally { + this._engagingShareId = null; + this.requestUpdate(); + } + } + + /** + * Shared renderer for the reviewed / engaged status toggles. + * @param {QueueCardRecord} record + * @param {{ on: boolean, busy: boolean, modifier: string, icon: string, + * onLabel: string, offLabel: string, busyLabel: string, missingLabel: string, + * onText: string, offText: string, click: () => void }} cfg + */ + _renderStatusToggle(record, cfg) { + const { shareId } = record; + const disabled = this._busy || !shareId; + let label = cfg.on ? cfg.onLabel : cfg.offLabel; + if (cfg.busy) label = cfg.busyLabel; + if (!shareId) label = cfg.missingLabel; + + return html` + + `; + } + + _renderReviewedButton(record) { + const reviewed = isReviewed(record); + return this._renderStatusToggle(record, { + on: reviewed, + busy: this._reviewingShareId === record.shareId, + modifier: 'reviewed', + icon: 'M6.7 13.3 2.9 9.5l1.2-1.2 2.6 2.6 7-7L15 4.1l-8.3 9.2Z', + onLabel: 'Reviewed — click to unmark', + offLabel: 'Mark as reviewed', + busyLabel: 'Updating reviewed state', + missingLabel: 'Review unavailable (no share key)', + onText: 'Reviewed', + offText: 'Mark reviewed', + click: () => this._onToggleReviewed(record), + }); + } + + _renderEngagedButton(record) { + const engaged = isEngaged(record); + return this._renderStatusToggle(record, { + on: engaged, + busy: this._engagingShareId === record.shareId, + modifier: 'engaged', + icon: 'M9 1.5a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15Zm3.3 5.6-3.9 3.9a.6.6 0 0 1-.85 0L5.7 9.05l.85-.85 1.6 1.6 3.5-3.5.85.8Z', + onLabel: 'Engaged — click to unmark', + offLabel: 'Mark as engaged', + busyLabel: 'Updating engaged state', + missingLabel: 'Engage unavailable (no share key)', + onText: 'Engaged', + offText: 'Mark engaged', + click: () => this._onToggleEngaged(record), + }); + } + _renderDeleteButton(record) { const { shareId } = record; const busy = this._deletingShareId === shareId; - const disabled = this._loading || Boolean(this._deletingShareId) - || Boolean(this._pendingDelete); + const disabled = this._busy; const missingKey = !shareId; let label = 'Delete submission'; if (busy) label = 'Deleting submission'; @@ -546,7 +873,7 @@ class EmaQueue extends LitElement { } _onExportCsvClick() { - const cards = this._cards; + const cards = this._visibleCards; if (!cards.length) return; const csv = recordsToCsv(cards); const date = new Date().toISOString().slice(0, 10); @@ -554,7 +881,7 @@ class EmaQueue extends LitElement { } get _canExport() { - return !this._loading && this._cards.length > 0; + return !this._loading && this._visibleCards.length > 0; } _renderLoadingModal() { @@ -608,6 +935,41 @@ class EmaQueue extends LitElement { `; } + _renderFilters() { + if (this._loading || this._error || this._data == null) return nothing; + if (!this._cards.length) return nothing; + + const counts = this._filterCounts; + const hasActive = this._activeFilters.length > 0; + + return html` +
+ Filter + ${FILTER_DEFS.map((f) => { + const active = this._activeFilters.includes(f.key); + return html` + + `; + })} + ${hasActive + ? html` + + ` + : nothing} +
+ `; + } + _renderStatus() { if (this._error) { return html` @@ -650,8 +1012,14 @@ class EmaQueue extends LitElement { const titleKeys = new Set([...TITLE_KEYS, ...SUBTITLE_KEYS]); const populated = getPopulatedFields(record); const incomplete = isIncompleteSubmission(record); + const complete = isCompleteSubmission(record); + const reviewed = isReviewed(record); + const engaged = isEngaged(record); + const subDate = submittedDate(record); + const statusKeys = new Set(['reviewed', 'engaged', 'date-submitted', 'dateSubmitted']); const detailFields = populated.filter( (f) => !titleKeys.has(f.key) && !chipKeys.has(f.key) + && !statusKeys.has(f.key) && !(incomplete && f.key === 'submitted'), ); const expanded = this._isExpanded(cardId); @@ -669,11 +1037,26 @@ class EmaQueue extends LitElement { ${incomplete ? html`Incomplete` : nothing} + ${complete + ? html`Complete` + : nothing} + ${reviewed + ? html`Reviewed` + : nothing} + ${engaged + ? html`Engaged` + : nothing}

${title}

${subtitle ? html`

${subtitle}

` : nothing} + ${subDate + ? html` +

+ Submitted ${subDate} +

` + : nothing} ${this._renderDeleteButton(record)} @@ -717,6 +1100,11 @@ class EmaQueue extends LitElement { ` : nothing} + +
+ ${this._renderReviewedButton(record)} + ${this._renderEngagedButton(record)} +
`; } @@ -724,11 +1112,24 @@ class EmaQueue extends LitElement { _renderResults() { if (this._loading || this._error || this._data == null) return nothing; - const cards = this._cards; - if (!cards.length) { + const total = this._cards.length; + if (!total) { return html`

No submissions returned.

`; } + const cards = this._visibleCards; + const filtered = this._activeFilters.length > 0; + const count = filtered + ? html`${cards.length} of ${total} submission${total === 1 ? '' : 's'}` + : html`${total} submission${total === 1 ? '' : 's'}`; + + if (!cards.length) { + return html` +

${count}

+

No submissions match these filters.

+ `; + } + let formatted; try { formatted = JSON.stringify(this._data, null, 2); @@ -737,7 +1138,7 @@ class EmaQueue extends LitElement { } return html` -

${cards.length} submission${cards.length === 1 ? '' : 's'}

+

${count}

${cards.map((record, index) => html`
@@ -775,6 +1176,7 @@ class EmaQueue extends LitElement { Export CSV
+ ${this._renderFilters()} ${this._renderStatus()} ${this._renderResults()} ${this._renderConfirmModal()} From 5d30a2fa8b05a4ec35afe3b6a838cfa1e593a841 Mon Sep 17 00:00:00 2001 From: Lamont Crook Date: Tue, 16 Jun 2026 17:13:31 -0600 Subject: [PATCH 02/11] Replace inline field expand with a details modal The "Show N more fields" inline expander is replaced by a "View all N fields" button that opens a modal showing the full submission record (every populated field, with long values like priority-justification wrapped). Closes via the X button, backdrop click, or Escape. Removes the per-card expand state. Co-Authored-By: Claude Opus 4.8 --- tools/queue/queue.css | 106 ++++++++++++++++++++++++++++++------ tools/queue/queue.js | 121 ++++++++++++++++++++++++++---------------- 2 files changed, 165 insertions(+), 62 deletions(-) diff --git a/tools/queue/queue.css b/tools/queue/queue.css index f66113e..dabd98f 100644 --- a/tools/queue/queue.css +++ b/tools/queue/queue.css @@ -289,34 +289,30 @@ ema-queue *::after { overflow-wrap: anywhere; } -.ema-queue__expand { +.ema-queue__view-details { + align-self: flex-start; margin: 0; - padding: 0; - border: none; - background: none; + padding: 5px 14px; + border: 1px solid var(--spectrum-gray-300); + border-radius: 999px; + background: var(--spectrum-gray-50); font: inherit; - font-weight: 600; + font-size: 13px; + font-weight: 500; color: var(--spectrum-blue-800); cursor: pointer; - text-align: left; - text-decoration: underline; - text-underline-offset: 2px; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; } -.ema-queue__expand:hover { +.ema-queue__view-details:hover { + background: var(--spectrum-blue-100); + border-color: var(--spectrum-blue-800); color: var(--spectrum-blue-900); } -.ema-queue__expand:focus-visible { +.ema-queue__view-details:focus-visible { outline: 2px solid var(--spectrum-blue-800); outline-offset: 2px; - border-radius: 2px; -} - -.ema-queue__detail { - margin: 12px 0 0; - padding: 12px 0 0; - border-top: 1px solid var(--spectrum-gray-200); } .ema-queue__detail-row { @@ -379,6 +375,82 @@ body.ema-queue-loading { max-width: 24rem; } +.ema-queue__modal-dialog--details { + align-items: stretch; + gap: 0; + width: 100%; + max-width: 42rem; + max-height: 82vh; + padding: 0; + overflow: hidden auto; + text-align: left; +} + +.ema-queue__details-header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 20px 24px 14px; + background: var(--spectrum-gray-50); + border-bottom: 1px solid var(--spectrum-gray-200); +} + +.ema-queue__details-heading { + min-width: 0; +} + +.ema-queue__details-title { + margin: 0; + font-size: var(--spectrum-font-size-300); + font-weight: 700; + line-height: 1.25; +} + +.ema-queue__details-subtitle { + margin: 2px 0 0; + font-size: var(--spectrum-font-size-100); + color: var(--spectrum-gray-700); +} + +.ema-queue__details-close { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + margin: 0; + padding: 0; + border: 1px solid transparent; + border-radius: var(--spectrum-corner-radius-100); + background: transparent; + color: var(--spectrum-gray-700); + cursor: pointer; +} + +.ema-queue__details-close:hover { + background: var(--spectrum-gray-100); + border-color: var(--spectrum-gray-300); +} + +.ema-queue__details-close:focus-visible { + outline: 2px solid var(--spectrum-blue-800); + outline-offset: 2px; +} + +.ema-queue__details-list { + margin: 0; + padding: 16px 24px 24px; +} + +.ema-queue__detail-value--modal { + white-space: pre-wrap; +} + .ema-queue__confirm-title { margin: 0; font-size: var(--spectrum-font-size-200); diff --git a/tools/queue/queue.js b/tools/queue/queue.js index a692fc8..b7b2711 100644 --- a/tools/queue/queue.js +++ b/tools/queue/queue.js @@ -500,7 +500,7 @@ class EmaQueue extends LitElement { _loading: { state: true }, _error: { state: true }, _data: { state: true }, - _expandedKeys: { state: true }, + _detailsCardId: { state: true }, _bootstrapped: { state: true }, _deletingShareId: { state: true }, _pendingDelete: { state: true }, @@ -518,7 +518,7 @@ class EmaQueue extends LitElement { this._loading = false; this._error = null; this._data = null; - this._expandedKeys = []; + this._detailsCardId = null; this._bootstrapped = false; this._deletingShareId = null; this._pendingDelete = null; @@ -555,12 +555,13 @@ class EmaQueue extends LitElement { } get _modalOpen() { - return this._loading || Boolean(this._pendingDelete); + return this._loading || Boolean(this._pendingDelete) || Boolean(this._detailsCardId); } updated(changed) { super.updated(changed); - if (changed.has('_loading') || changed.has('_pendingDelete')) { + if (changed.has('_loading') || changed.has('_pendingDelete') + || changed.has('_detailsCardId')) { document.body.classList.toggle('ema-queue-loading', this._modalOpen); } } @@ -605,18 +606,21 @@ class EmaQueue extends LitElement { writeFiltersToUrl(this._activeFilters); } - _isExpanded(cardId) { - return this._expandedKeys.includes(cardId); + /** Record whose details modal is open, resolved from the current card list. */ + get _detailsRecord() { + if (!this._detailsCardId) return null; + return this._cards.find((c) => c.cardId === this._detailsCardId) || null; } /** - * @param {string} cardId + * @param {QueueCardRecord} record */ - _toggleExpand(cardId) { - const keys = new Set(this._expandedKeys); - if (keys.has(cardId)) keys.delete(cardId); - else keys.add(cardId); - this._expandedKeys = [...keys]; + _openDetails(record) { + this._detailsCardId = record.cardId; + } + + _closeDetails() { + this._detailsCardId = null; } async loadShares() { @@ -625,13 +629,11 @@ class EmaQueue extends LitElement { this._error = 'Fusion endpoint not configured. Add fusion.endpoint in repo DA config.'; return; } - const preserveExpanded = this._expandedKeys; this._loading = true; this._error = null; this.requestUpdate(); try { this._data = await postShareAll(endpoint); - this._expandedKeys = preserveExpanded; } catch (err) { this._data = null; const msg = err instanceof Error ? err.message : 'Request failed'; @@ -935,6 +937,59 @@ class EmaQueue extends LitElement { `; } + _renderDetailsModal() { + const record = this._detailsRecord; + if (!record) return nothing; + + const title = pickField(record, TITLE_KEYS) || 'Submission'; + const subtitle = pickField(record, SUBTITLE_KEYS); + const fields = getPopulatedFields(record); + + return html` + + `; + } + _renderFilters() { if (this._loading || this._error || this._data == null) return nothing; if (!this._cards.length) return nothing; @@ -1008,26 +1063,16 @@ class EmaQueue extends LitElement { const title = pickField(record, TITLE_KEYS) || '(No account)'; const subtitle = pickField(record, SUBTITLE_KEYS); const chips = getChipFields(record); - const chipKeys = new Set(chips.map((c) => c.key)); - const titleKeys = new Set([...TITLE_KEYS, ...SUBTITLE_KEYS]); - const populated = getPopulatedFields(record); + const fieldCount = getPopulatedFields(record).length; const incomplete = isIncompleteSubmission(record); const complete = isCompleteSubmission(record); const reviewed = isReviewed(record); const engaged = isEngaged(record); const subDate = submittedDate(record); - const statusKeys = new Set(['reviewed', 'engaged', 'date-submitted', 'dateSubmitted']); - const detailFields = populated.filter( - (f) => !titleKeys.has(f.key) && !chipKeys.has(f.key) - && !statusKeys.has(f.key) - && !(incomplete && f.key === 'submitted'), - ); - const expanded = this._isExpanded(cardId); - const moreCount = detailFields.length; return html`
@@ -1074,33 +1119,18 @@ class EmaQueue extends LitElement { ` : nothing} - ${moreCount > 0 + ${fieldCount > 0 ? html` ` : nothing} - ${expanded && moreCount > 0 - ? html` -
- ${detailFields.map((field) => html` -
-
${field.label}
-
${field.value}
-
- `)} -
- ` - : nothing} -
${this._renderReviewedButton(record)} ${this._renderEngagedButton(record)} @@ -1179,6 +1209,7 @@ class EmaQueue extends LitElement { ${this._renderFilters()} ${this._renderStatus()} ${this._renderResults()} + ${this._renderDetailsModal()} ${this._renderConfirmModal()} ${this._renderLoadingModal()}
From ba63b7a31ea1fb5faf3405dd2251eb1de4cf9e22 Mon Sep 17 00:00:00 2001 From: Lamont Crook Date: Tue, 16 Jun 2026 17:15:31 -0600 Subject: [PATCH 03/11] Hide status flags from card count and details modal reviewed, engaged, and submitted are represented by badges/toggles, so they no longer appear as raw field rows in the details modal or the "View all N fields" count. They remain in the CSV export. Co-Authored-By: Claude Opus 4.8 --- tools/queue/queue.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/queue/queue.js b/tools/queue/queue.js index b7b2711..636cc34 100644 --- a/tools/queue/queue.js +++ b/tools/queue/queue.js @@ -397,13 +397,19 @@ function normalizeFusionQueue(payload) { return records; } +/** + * Status flags shown as badges/toggles rather than as raw field rows. + * Hidden from the card field count and the details modal (still exported to CSV). + */ +const DISPLAY_HIDDEN_KEYS = new Set(['reviewed', 'engaged', 'submitted']); + /** * @param {QueueCardRecord} record * @returns {{ key: string, label: string, value: string }[]} */ function getPopulatedFields(record) { return Object.entries(record) - .filter(([key]) => !META_FIELD_KEYS.has(key)) + .filter(([key]) => !META_FIELD_KEYS.has(key) && !DISPLAY_HIDDEN_KEYS.has(key)) .filter(([, value]) => isPresent(value)) .map(([key, value]) => ({ key, From a3dbc7b2aa52d391979d202f67565a9858bc522b Mon Sep 17 00:00:00 2001 From: Lamont Crook Date: Tue, 16 Jun 2026 17:22:05 -0600 Subject: [PATCH 04/11] Wire reviewed/engaged toggles to the status webhook The Reviewed/Engaged toggles now POST { key, reviewed } / { key, engaged } to a dedicated Fusion status webhook (STATUS_ENDPOINT), separate from the list/delete endpoint. The endpoint is injectable via the statusEndpoint property so the local dev harness can stub it. Co-Authored-By: Claude Opus 4.8 --- tools/queue/queue.js | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/tools/queue/queue.js b/tools/queue/queue.js index 636cc34..4cf0c1c 100644 --- a/tools/queue/queue.js +++ b/tools/queue/queue.js @@ -1,11 +1,14 @@ /** * DA-embedded queue tool: lists form shares via Fusion webhook. * - * POST list: { share: "all" } - * POST delete: { delete: "" } - * POST review: { review: "", reviewed: } - * POST engage: { engage: "", engaged: } - * Endpoint: DA repo config → fusion sheet → key `endpoint` (see config/repo-config.example.json). + * List/delete endpoint: DA repo config → fusion sheet → key `endpoint` + * (see config/repo-config.example.json). + * POST list: { share: "all" } + * POST delete: { delete: "" } + * + * Status endpoint (STATUS_ENDPOINT below) — persists reviewer actions: + * POST review: { key: "", reviewed: } + * POST engage: { key: "", engaged: } * * Fusion list response: array of { json: "" } wrappers. * Each record includes `key` and the status fields `reviewed`, `engaged`, and @@ -21,6 +24,9 @@ import { initSpectrum } from '../shared/spectrum-theme.js'; const EL_NAME = 'ema-queue'; +/** Fusion webhook that persists reviewer actions (reviewed / engaged). */ +const STATUS_ENDPOINT = 'https://hook.fusion.adobe.com/dp0w3vskkydd6g9z4hnra9hp45qf6qi4'; + const TITLE_KEYS = ['account-name', 'accountName']; const SUBTITLE_KEYS = ['submitter-name', 'submitterName']; @@ -124,7 +130,7 @@ function postShareDelete(endpoint, shareId) { * @returns {Promise} */ function postShareReview(endpoint, shareId, reviewed) { - return postFusion(endpoint, { review: shareId, reviewed }); + return postFusion(endpoint, { key: shareId, reviewed }); } /** @@ -135,7 +141,7 @@ function postShareReview(endpoint, shareId, reviewed) { * @returns {Promise} */ function postShareEngage(endpoint, shareId, engaged) { - return postFusion(endpoint, { engage: shareId, engaged }); + return postFusion(endpoint, { key: shareId, engaged }); } /** @@ -500,6 +506,7 @@ function downloadCsv(csv, filename) { class EmaQueue extends LitElement { static properties = { fusionEndpoint: { attribute: false }, + statusEndpoint: { attribute: false }, details: { attribute: false }, context: { attribute: false }, adminOrigin: { attribute: false }, @@ -518,6 +525,7 @@ class EmaQueue extends LitElement { constructor() { super(); this.fusionEndpoint = null; + this.statusEndpoint = STATUS_ENDPOINT; this.details = {}; this.context = null; this.adminOrigin = 'https://admin.da.live'; @@ -722,9 +730,9 @@ class EmaQueue extends LitElement { this.requestUpdate(); return; } - const endpoint = this.fusionEndpoint; + const endpoint = this.statusEndpoint; if (!endpoint) { - this._error = 'Fusion endpoint not configured. Add fusion.endpoint in repo DA config.'; + this._error = 'Status endpoint not configured.'; this.requestUpdate(); return; } @@ -756,9 +764,9 @@ class EmaQueue extends LitElement { this.requestUpdate(); return; } - const endpoint = this.fusionEndpoint; + const endpoint = this.statusEndpoint; if (!endpoint) { - this._error = 'Fusion endpoint not configured. Add fusion.endpoint in repo DA config.'; + this._error = 'Status endpoint not configured.'; this.requestUpdate(); return; } From 315ed00b4b5b6f7d867daa8cbd5b88f5ed93dfc9 Mon Sep 17 00:00:00 2001 From: Lamont Crook Date: Tue, 16 Jun 2026 17:27:45 -0600 Subject: [PATCH 05/11] Fold dev harness into queue.html behind ?dev flag queue.html now mounts a stubbed dev harness (sample data + in-memory Fusion webhooks) when opened with ?dev on non-prod environments, so the UI can be previewed on plain localhost without DA sign-in. Without the flag (or on aem.live) it loads the real tool via init() as before. Replaces the separate queue-dev.html / __dev-data.json with queue-dev.js (fictional sample data). Co-Authored-By: Claude Opus 4.8 --- tools/queue/queue-dev.js | 191 +++++++++++++++++++++++++++++++++++++++ tools/queue/queue.css | 11 +++ tools/queue/queue.html | 15 ++- 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 tools/queue/queue-dev.js diff --git a/tools/queue/queue-dev.js b/tools/queue/queue-dev.js new file mode 100644 index 0000000..8cc0ef1 --- /dev/null +++ b/tools/queue/queue-dev.js @@ -0,0 +1,191 @@ +/** + * Local dev harness for the queue tool, folded into queue.html behind `?dev`. + * + * Renders with sample data and an in-memory stub of the Fusion + * webhooks, so the UI (filters, CSV, details modal, reviewed/engaged toggles) + * can be exercised on plain localhost without DA sign-in. Gated to non-prod + * environments by queue.html. Sample data is fictional. + */ +import './queue.js'; + +const STUB_ENDPOINT = 'https://hook.fusion.adobe.com/local-dev-stub'; + +/** Fictional submissions covering every status combination. */ +const SAMPLE_RECORDS = [ + { + key: 'dev-northwind', + 'account-name': 'Northwind Traders', + 'submitter-name': 'Dana Pierce', + 'dr-number': 'DR1000001', + 'migration-type': '1:1 Migration', + 'is-xsc-engaged': 'Yes', + 'eds-awareness-level': 'Aware', + 'xsc-eds-da-demo': 'Within a Year', + 'account-director-name': 'Robin Hale', + submitted: 'true', + reviewed: false, + engaged: false, + 'date-submitted': '2026-06-09', + }, + { + key: 'dev-globex', + 'account-name': 'Globex Corporation', + 'submitter-name': 'Avery Lin', + 'dr-number': 'DR1000002', + 'migration-type': '1:1 Migration', + 'is-xsc-engaged': 'Yes', + 'eds-awareness-level': 'Actively evaluating(ed)', + 'xsc-eds-da-demo': 'Recently (within 4-8 wks)', + 'account-director-name': 'Sky Monroe', + submitted: 'true', + reviewed: true, + engaged: false, + 'date-submitted': '2026-06-12T15:30:00Z', + }, + { + key: 'dev-initech', + 'account-name': 'Initech', + 'submitter-name': 'Sam Carter', + 'dr-number': 'DR1000003', + 'migration-type': '1:1 Migration', + 'is-xsc-engaged': 'Yes', + 'eds-awareness-level': 'Actively evaluating(ed)', + 'xsc-eds-da-demo': 'At Summit', + 'account-director-name': 'Lee Okafor', + submitted: 'true', + reviewed: false, + engaged: true, + 'date-submitted': '2026-05-27', + }, + { + key: 'dev-soylent', + 'account-name': 'Soylent Industries', + 'submitter-name': 'Morgan Reyes', + 'dr-number': 'DR1000004', + 'migration-type': '1:1 Migration', + 'is-xsc-engaged': 'Yes', + 'eds-awareness-level': 'Aware', + 'xsc-eds-da-demo': 'Within a Year', + 'account-director-name': 'Quinn Adler', + submitted: 'true', + reviewed: true, + engaged: true, + 'date-submitted': '2026-05-31', + }, + { + key: 'dev-umbra', + 'account-name': 'Umbra Logistics', + 'submitter-name': 'Jordan Fox', + 'dr-number': 'DR1000005', + 'migration-type': '1:1 Migration', + 'is-xsc-engaged': 'No', + 'eds-awareness-level': 'Aware', + 'xsc-eds-da-demo': 'Not yet', + 'account-director-name': 'Drew Bell', + submitted: 'true', + reviewed: false, + engaged: false, + 'date-submitted': '2026-06-02', + }, + { + key: 'dev-vandelay', + 'account-name': 'Vandelay Industries', + 'submitter-name': 'Pat Quinn', + 'account-director-name': 'Alex Stone', + 'priority-justification': 'Existing on-prem customer mid-RFP to move to the cloud.\nStrong renewal-timing pressure and an executive sponsor make this a high-priority pilot candidate.', + submitted: 'false', + reviewed: false, + engaged: false, + }, + { + key: 'dev-stark', + 'account-name': 'Stark Manufacturing', + 'submitter-name': 'Riley Chen', + 'dr-number': 'DR1000007', + 'migration-type': '1:1 Migration', + 'is-xsc-engaged': 'Yes', + 'eds-awareness-level': 'Aware', + 'xsc-eds-da-demo': 'Recently (within 4-8 wks)', + 'account-director-name': 'Sage Turner', + 'priority-justification': 'Renewal in Q3 creates a compelling event.\nMarketing wants faster time-to-market; current publishing requires developer involvement, a major bottleneck.', + submitted: 'true', + reviewed: false, + engaged: false, + 'date-submitted': '2026-06-05', + }, + { + key: 'dev-wayne', + 'account-name': 'Wayne Enterprises', + 'submitter-name': 'Casey Brooks', + 'dr-number': 'DR1000008', + 'migration-type': '1:1 Migration', + 'is-xsc-engaged': 'Yes', + 'eds-awareness-level': 'Already piloting(ed)', + 'xsc-eds-da-demo': 'Recently (within 4-8 wks)', + 'account-director-name': 'Noor Vance', + submitted: 'true', + reviewed: false, + engaged: false, + 'date-submitted': '2026-06-14', + }, +]; + +/** + * @param {Record[]} records mutable in-memory store + * @returns {(url: string, opts?: RequestInit) => Promise} + */ +function makeStubFetch(records) { + const realFetch = window.fetch.bind(window); + const findByKey = (key) => records.find((r) => String(r.key) === String(key)); + const asWrappers = () => records.map((r) => ({ json: JSON.stringify(r) })); + const ok = (payload) => new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + return async (url, opts = {}) => { + if (url !== STUB_ENDPOINT) return realFetch(url, opts); + const body = JSON.parse(opts.body || '{}'); + if (body.share === 'all') return ok(asWrappers()); + if ('delete' in body) { + const i = records.findIndex((r) => String(r.key) === String(body.delete)); + if (i >= 0) records.splice(i, 1); + return ok({ ok: true }); + } + if ('reviewed' in body) { + const rec = findByKey(body.key); + if (rec) rec.reviewed = Boolean(body.reviewed); + return ok({ ok: true }); + } + if ('engaged' in body) { + const rec = findByKey(body.key); + if (rec) rec.engaged = Boolean(body.engaged); + return ok({ ok: true }); + } + return ok({ ok: true }); + }; +} + +/** + * Mount the queue in dev mode with stubbed Fusion webhooks and sample data. + * @param {HTMLElement} root + */ +export default function mountDevQueue(root) { + const records = SAMPLE_RECORDS.map((r) => ({ ...r })); + window.fetch = makeStubFetch(records); + + const banner = document.createElement('p'); + banner.className = 'ema-queue__dev-banner'; + banner.textContent = 'Dev mode — Fusion webhooks are stubbed in-memory with sample data. ' + + 'Filters, CSV, the details modal, and the Reviewed/Engaged toggles all work; ' + + 'changes persist until you reload.'; + + // Setting the endpoints lets the component's normal firstUpdated() → + // loadShares() flow fetch the sample data through the stub above. + const cmp = document.createElement('ema-queue'); + cmp.fusionEndpoint = STUB_ENDPOINT; + cmp.statusEndpoint = STUB_ENDPOINT; + cmp.adminOrigin = 'https://admin.da.live'; + + root.replaceChildren(banner, cmp); +} diff --git a/tools/queue/queue.css b/tools/queue/queue.css index dabd98f..1803c01 100644 --- a/tools/queue/queue.css +++ b/tools/queue/queue.css @@ -1,6 +1,17 @@ /** * Spectrum-style surface for the Queue tool (Lit + light DOM). */ +.ema-queue__dev-banner { + margin: 0; + padding: 8px 16px; + font-family: adobe-clean, 'Source Sans 3', system-ui, sans-serif; + font-size: 13px; + line-height: 1.4; + color: #553800; + background: #fdf0e1; + border-bottom: 1px solid #f0c98a; +} + ema-queue { --spectrum-gray-50: #fff; --spectrum-gray-100: #f8f8f8; diff --git a/tools/queue/queue.html b/tools/queue/queue.html index f99d4da..804ec22 100644 --- a/tools/queue/queue.html +++ b/tools/queue/queue.html @@ -10,9 +10,20 @@
From 0f7494a9a1b36024d3e086bf0dffc7a962697aea Mon Sep 17 00:00:00 2001 From: Lamont Crook Date: Tue, 16 Jun 2026 17:35:48 -0600 Subject: [PATCH 06/11] Load queue in DA like the inventory tool (daFetch + URL org/repo) The tool failed to load inside Document Authoring because it read sdk.token (undefined in the current SDK) and sdk.context.org/repo (often empty in the embedded app). Mirror the working inventory tool instead: - authenticate config reads via sdk.actions.daFetch (da-config now takes daFetch) - resolve org/repo from the embedded-browser URL (parseDaOrgRepoFromEmbeddedBrowsers) - self-boot from a
- diff --git a/tools/queue/queue.js b/tools/queue/queue.js index 4cf0c1c..9ee1386 100644 --- a/tools/queue/queue.js +++ b/tools/queue/queue.js @@ -17,7 +17,8 @@ */ import DA_SDK from 'https://da.live/nx/utils/sdk.js'; import { LitElement, html, nothing } from '../../deps/lit/dist/index.js'; -import { loadFusionEndpointFromConfig } from '../shared/da-config.js'; +import ENV from '../../scripts/utils/env.js'; +import { loadFusionEndpointFromConfig, parseDaOrgRepoFromEmbeddedBrowsers } from '../shared/da-config.js'; import { initSpectrum } from '../shared/spectrum-theme.js'; /* global getBlockDetails */ @@ -1234,15 +1235,15 @@ class EmaQueue extends LitElement { customElements.define(EL_NAME, EmaQueue); export default async function init(el) { - let token = null; + let daFetch = null; let context = null; let adminOrigin = 'https://admin.da.live'; try { const sdk = await DA_SDK; - token = sdk.token ?? null; + daFetch = sdk.actions?.daFetch ?? null; context = sdk.context ?? null; } catch { - /* DA parent not available */ + /* DA parent not available (standalone) */ } try { const mod = await import('https://da.live/nx/public/utils/constants.js'); @@ -1251,22 +1252,30 @@ export default async function init(el) { /* default adminOrigin */ } + // org/repo: DA SDK context first, then the embedded-browser URL (the editor + // hash carries org/repo even when the SDK context does not). + const base = context ? { ...context } : {}; + const urlOrgRepo = parseDaOrgRepoFromEmbeddedBrowsers(); + if (urlOrgRepo) { + base.org = urlOrgRepo.org; + base.repo = urlOrgRepo.repo; + } + const org = base.org?.trim(); + const repo = base.repo?.trim(); + const cmp = document.createElement(EL_NAME); cmp.details = typeof getBlockDetails === 'function' ? getBlockDetails(el) : {}; - cmp.context = context && Object.keys(context).length ? { ...context } : null; + cmp.context = Object.keys(base).length ? base : null; cmp.adminOrigin = adminOrigin; - const org = context?.org?.trim(); - const repo = context?.repo?.trim(); - - if (!token) { + if (!daFetch) { cmp.primeError('Sign in to Document Authoring to load the queue.'); } else if (!org || !repo) { cmp.primeError('Open the queue from Document Authoring (org and repo context required).'); } else { try { const endpoint = await loadFusionEndpointFromConfig({ - token, + daFetch, adminOrigin, org, repo, @@ -1285,3 +1294,32 @@ export default async function init(el) { el.replaceChildren(); el.append(cmp); } + +/** Mount into #queue-root, with ?dev sample-data mode on non-prod environments. */ +async function boot() { + const root = document.getElementById('queue-root'); + if (!root) return; + const devRequested = new URLSearchParams(window.location.search).has('dev'); + try { + if (ENV !== 'prod' && devRequested) { + const { default: mountDevQueue } = await import('./queue-dev.js'); + mountDevQueue(root); + } else { + await init(root); + } + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + root.replaceChildren(); + const p = document.createElement('p'); + p.textContent = `Could not start the queue: ${err?.message ?? err}`; + p.style.cssText = 'padding:1rem;font-family:system-ui,sans-serif'; + root.append(p); + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); +} else { + boot(); +} diff --git a/tools/shared/da-config.js b/tools/shared/da-config.js index 29b590d..5a156f0 100644 --- a/tools/shared/da-config.js +++ b/tools/shared/da-config.js @@ -39,7 +39,8 @@ export function parseFusionEndpoint(config) { /** * @param {Object} opts - * @param {string} opts.token IMS bearer from DA_SDK + * @param {(url: string, init?: RequestInit) => Promise} [opts.daFetch] + * Authenticated fetch from DA_SDK (sdk.actions.daFetch); falls back to plain fetch. * @param {string} opts.adminOrigin * @param {string} opts.org * @param {string} opts.repo @@ -48,7 +49,7 @@ export function parseFusionEndpoint(config) { * @returns {Promise} */ export async function fetchDaRepoConfig({ - token, + daFetch, adminOrigin, org, repo, @@ -57,12 +58,10 @@ export async function fetchDaRepoConfig({ }) { const base = String(adminOrigin ?? '').replace(/\/+$/, ''); const url = `${base}/config/${encodeURIComponent(org)}/${encodeURIComponent(repo)}/${path}`; - const res = await fetch(url, { + const doFetch = typeof daFetch === 'function' ? daFetch : fetch; + const res = await doFetch(url, { method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/json', - }, + headers: { Accept: 'application/json' }, signal, }); if (res.status === 401) { @@ -77,7 +76,7 @@ export async function fetchDaRepoConfig({ /** * @param {Object} opts - * @param {string} opts.token + * @param {(url: string, init?: RequestInit) => Promise} [opts.daFetch] * @param {string} opts.adminOrigin * @param {string} opts.org * @param {string} opts.repo @@ -90,3 +89,72 @@ export async function loadFusionEndpointFromConfig(opts) { if (!config) return null; return parseFusionEndpoint(config); } + +/** + * Reads org/repo from a DA-style hash route `#/org/repo/...`. + * @param {string} href + * @returns {{ org: string, repo: string } | null} + */ +function parseDaOrgRepoFromHref(href) { + try { + const u = new URL(href); + const raw = (u.hash ?? '').replace(/^#\/?/, '').replace(/\/+$/, ''); + const parts = raw.split('/').filter(Boolean); + if (parts.length < 2) return null; + const org = parts[0].trim(); + const repo = parts[1].trim(); + if (!org || !repo) return null; + return { org, repo }; + } catch { + return null; + } +} + +/** + * `?org=` and `?repo=` on the page URL (explicit override for embedded tools). + * @param {string} href + * @returns {{ org: string, repo: string } | null} + */ +function parseDaOrgRepoFromHrefSearchParams(href) { + try { + const u = new URL(href); + const org = (u.searchParams.get('org') ?? '').trim(); + const repo = (u.searchParams.get('repo') ?? '').trim(); + if (!org || !repo) return null; + return { org, repo }; + } catch { + return null; + } +} + +/** + * Resolves org/repo from the embedded windows (self, parent, top). DA editor + * runs tools in an iframe whose own URL may lack the org/repo hash that the + * parent frame carries, so all reachable frame URLs are checked. + * @returns {{ org: string, repo: string } | null} + */ +export function parseDaOrgRepoFromEmbeddedBrowsers() { + /** @type {string[]} */ + const hrefs = []; + const tryPush = (loc) => { + try { + const h = loc?.href; + if (typeof h === 'string' && h && !hrefs.includes(h)) hrefs.push(h); + } catch { + /* cross-origin frame: reading .href throws */ + } + }; + tryPush(globalThis.location); + if (globalThis.parent !== globalThis) tryPush(globalThis.parent.location); + if (globalThis.top !== globalThis) tryPush(globalThis.top.location); + + for (const href of hrefs) { + const q = parseDaOrgRepoFromHrefSearchParams(href); + if (q) return q; + } + for (const href of hrefs) { + const p = parseDaOrgRepoFromHref(href); + if (p) return p; + } + return null; +} From 3361545f23f75c5615152fe7b177bde28336e926 Mon Sep 17 00:00:00 2001 From: Lamont Crook Date: Tue, 16 Jun 2026 17:56:32 -0600 Subject: [PATCH 07/11] Remove the ?dev sample-data harness Drops queue-dev.js, the ?dev branch in boot(), the ENV import, and the dev banner style. queue.html keeps the standard self-boot (