From 393cb066309b91cc96c4cf58ad44aeb46a331bb8 Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Mon, 20 Apr 2026 14:28:41 -0400 Subject: [PATCH 01/20] feat: UX alignment for Request for Publish plugin and Publish Requests Inbox DA-native visual alignment for both the request-for-publish plugin and the publish-requests-inbox app. Plugin and app: - Adopt nexter design tokens, buttons.css, SL styles; flatten UI chrome - Per-customer accent color override via workflow-settings (theme.*) - Load nexter.css in plugin and app HTML for token inheritance - Consistent typography, spacing, form, and link treatments - Subtle red .message.error banner with DA-native styling; other message variants aligned (success, info, warning) Plugin (request-for-publish): - Polish My Requests (requester) view for visual consistency - Dedicated withdrawn screen instead of falling back to form - Consolidate validation and backend errors on _message via renderMessage() so backend errors surface in the form - -wrapped emails and paths for scannability - Consistent 700 font-weight on links; aligned dd/code padding App (publish-requests-inbox): - Tighten inbox rows, remove empty icon spans - Approvers section uses accent-derived color-mix highlight - Negative (red) button for Reject Request - Muted gray reject toggle; primary class for reject button - Override nexter.css body grid layout in app iframe - Neutral gray loading spinner to avoid accent color flash Misc: - Add loadButtons helper for nexter button styles - Match nexter specificity for disabled accent buttons; preserve white text on disabled accent buttons - Replace hardcoded blue with accent-derived colors throughout Made-with: Cursor --- tools/apps/publish-requests-inbox/api.js | 16 + .../publish-requests-inbox.css | 1093 +++++++---------- .../publish-requests-inbox.html | 5 +- .../publish-requests-inbox.js | 811 ++++++------ tools/plugins/request-for-publish/README.md | 19 +- .../request-for-publish.css | 602 ++++----- .../request-for-publish.html | 3 +- .../request-for-publish.js | 336 ++--- tools/plugins/request-for-publish/utils.js | 8 + tools/scripts/utils/styles.js | 6 + 10 files changed, 1414 insertions(+), 1485 deletions(-) diff --git a/tools/apps/publish-requests-inbox/api.js b/tools/apps/publish-requests-inbox/api.js index 1be8c02..6cb8f5b 100644 --- a/tools/apps/publish-requests-inbox/api.js +++ b/tools/apps/publish-requests-inbox/api.js @@ -228,6 +228,22 @@ export function getLiveHostFromConfig(org, site, config) { return `main--${site}--${org}.aem.live`; } +/** + * Fetch per-customer accent color overrides from workflow settings. + * @param {string} org - Organization + * @param {string} site - Site + * @param {string} token - Authorization token + * @returns {Promise<{accentColor: string|null, accentColorHover: string|null}>} + */ +export async function fetchAccentSettings(org, site, token) { + const config = await fetchWorkflowConfig(org, site, token); + if (!config) return { accentColor: null, accentColorHover: null }; + return { + accentColor: extractSetting(config, 'theme.accent-color'), + accentColorHover: extractSetting(config, 'theme.accent-color-hover'), + }; +} + /** * Publish content via Helix Admin API * POST https://admin.hlx.page/live/{org}/{site}/main/{path} diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.css b/tools/apps/publish-requests-inbox/publish-requests-inbox.css index ef37195..d9f9816 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.css +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.css @@ -1,144 +1,163 @@ -/* =========================== - Publish Requests App - Design tokens aligned with DA.live (nexter.css) - See: https://github.com/adobe/da-live - Context: full-page application - =========================== */ - :host { - /* DA.live design tokens (from nexter.css) */ - --body-font-family: "Adobe Clean", adobe-clean, "Trebuchet MS", sans-serif; - --fixed-font-family: "Roboto Mono", menlo, consolas, "Liberation Mono", monospace; - - /* Colors — Spectrum 2 palette */ - --s2-blue-200: #e5f0fe; - --s2-blue-700: #5989ff; - --s2-blue-800: #3b63fb; - --s2-blue-900: #3b63fb; - --s2-blue-1000: #274dea; - --s2-blue-1200: #1532ad; - --s2-green: #009851; - --s2-red: #f73b26; - --s2-red-200: #fde8e6; - --s2-red-500: #990033; - --s2-red-600: #730026; - --s2-red-1200: #670800; - --s2-gray-50: #f7f7f7; - --s2-gray-75: #f1f1f1; - --s2-gray-200: #e1e1e1; - --s2-gray-700: #505050; - --s2-gray-800: #292929; - --s2-gray-900: #121212; - - /* Spacing */ - --spacing-50: 2px; - --spacing-75: 4px; - --spacing-100: 8px; - --spacing-200: 12px; - --spacing-300: 16px; - --spacing-400: 24px; - --spacing-500: 32px; - --spacing-600: 40px; - --spacing-700: 48px; - --spacing-800: 64px; - - /* Corner radii */ - --s2-radius-75: 4px; - --s2-radius-100: 8px; - --s2-radius-200: 10px; - --s2-radius-300: 16px; - - /* Font sizing */ - --s2-font-size-100: 14px; - --s2-font-size-200: 16px; - --s2-font-size-300: 18px; - --s2-font-size-400: 20px; - - /* Heading sizing */ - --s2-heading-size-300: 18px; - --s2-heading-size-400: 20px; - --s2-heading-size-500: 22px; - --s2-heading-size-700: 28px; - --s2-heading-size-900: 36px; - - /* Semantic aliases (for backward compat / readability) */ - --color-primary: var(--s2-red-500); - --color-primary-hover: var(--s2-red-600); - --color-success: var(--s2-red-500); - --color-danger: var(--s2-red); - --color-text: var(--s2-gray-800); - --color-text-muted: var(--s2-gray-700); - --color-bg-light: var(--s2-gray-50); - --color-bg-code: var(--s2-gray-75); - --color-border: var(--s2-gray-200); - --color-link: var(--s2-blue-800); - --color-link-hover: var(--s2-blue-1000); + --pw-accent: var(--s2-blue-900); + --pw-accent-hover: var(--s2-blue-1000); display: block; - max-width: 1000px; - margin: 0 auto; - padding: var(--spacing-400); + max-width: var(--grid-container-width); + margin: var(--spacing-800) auto var(--spacing-800) auto; -webkit-font-smoothing: antialiased; - font-family: var(--body-font-family, "Adobe Clean", adobe-clean, "Trebuchet MS", sans-serif); - color: var(--s2-gray-800, #292929); + font-family: var(--body-font-family); + color: var(--s2-gray-800); +} + +/* + * sl-button hosts must NOT use nexter class names (.accent, .negative, .action): + * da.live/nx/styles/buttons.css targets those classes and paints the sl-button + * host while ::part(base) styles the inner button — causing a nested “double pill”. + */ +sl-button.pw-fill-accent, +sl-button.pw-fill-negative, +sl-button.pw-quiet-secondary { + background: transparent; + border: none; + padding: 0; + vertical-align: middle; +} + +sl-button.pw-fill-accent::part(base) { + background: var(--pw-accent); + border-color: var(--pw-accent); + color: var(--s2-gray-75); +} + +sl-button.pw-fill-accent::part(base):hover:not(:disabled) { + background: var(--pw-accent-hover); + border-color: var(--pw-accent-hover); +} + +sl-button.pw-fill-accent::part(base):disabled { + opacity: 0.4; + cursor: not-allowed; +} + +sl-button.pw-fill-negative::part(base) { + background: var(--s2-red-900); + border-color: var(--s2-red-900); + color: var(--s2-gray-75); +} + +sl-button.pw-fill-negative::part(base):hover:not(:disabled) { + filter: brightness(0.92); +} + +sl-button.pw-fill-negative::part(base):disabled { + opacity: 0.4; + cursor: not-allowed; +} + +sl-button.pw-quiet-secondary::part(base) { + background: transparent; + border: 1px solid var(--s2-gray-300); + color: var(--s2-gray-800); +} + +sl-button.pw-quiet-secondary::part(base):hover:not(:disabled) { + background: var(--s2-gray-75); + border-color: var(--s2-gray-400); } -/* ========== Site Selection ========== */ +/* sl-input / sl-textarea fill row (field chrome is inside sl shadow) */ +.form-group sl-input, +.form-group sl-textarea, +.site-select-field sl-input { + display: block; + width: 100%; +} + +/* Spectrum-style busy state (progress circle–like) */ +.spectrum-loading-indicator { + width: 32px; + height: 32px; + border-radius: 50%; + border: 3px solid var(--s2-gray-200); + border-top-color: var(--pw-accent); + animation: pw-spectrum-spin 0.85s linear infinite; +} + +.loading-label { + margin: 0; + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); +} + +@keyframes pw-spectrum-spin { + to { + transform: rotate(360deg); + } +} + + .site-select-container { - max-width: 480px; - margin: var(--spacing-700) auto 0; + max-width: 100%; + margin: 0; + padding-bottom: var(--spacing-400); } .site-select-header { - margin-bottom: var(--spacing-400); - padding-bottom: var(--spacing-300); - border-bottom: 2px solid var(--color-border); + margin: 0 0 var(--spacing-400) 0; + padding: 0; + border: none; } -.site-select-header h1 { - margin: 0 0 var(--spacing-100) 0; - font-size: var(--s2-heading-size-700); +.site-select-title { + margin: 0; + font-size: var(--s2-heading-xl-size, var(--s2-heading-size-700)); + line-height: 1.15; font-weight: 700; - color: var(--color-text); + color: var(--s2-gray-900); + letter-spacing: -0.02em; } -.site-select-subtitle { - margin: 0; - color: var(--color-text-muted); - font-size: var(--s2-font-size-200); +.site-select-toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-400); } -.site-select-form { +.site-select-field--grow { + flex: 1 1 18rem; + min-width: 12rem; display: flex; flex-direction: column; - gap: var(--spacing-300); + gap: 0; } -.site-select-form .form-group input { - width: 100%; - padding: 10px var(--spacing-200); - border: 1px solid var(--color-border); - border-radius: var(--s2-radius-100); - font-size: var(--s2-font-size-100); - font-family: var(--body-font-family); - box-sizing: border-box; - color: var(--color-text); +.site-select-submit { + flex: 0 0 auto; + align-self: flex-end; } -.site-select-form .form-group input:focus { - outline: none; - border-color: var(--color-primary); - box-shadow: 0 0 0 3px rgba(153, 0, 51, 0.15); -} +@media (max-width: 600px) { + .site-select-toolbar { + flex-direction: column; + align-items: stretch; + } -.site-select-form .form-group input::placeholder { - color: var(--s2-gray-200); + .site-select-submit { + align-self: stretch; + width: 100%; + } + + .site-select-field--grow { + flex: 1 1 100%; + min-width: 0; + } } -.btn-large { - width: 100%; - padding: 14px var(--spacing-500); - font-size: var(--s2-font-size-200); +/* ========== Content area below toolbar ========== */ +.pw-content { + padding-top: var(--spacing-400); } /* ========== Loading ========== */ @@ -147,404 +166,283 @@ flex-direction: column; align-items: center; justify-content: center; + gap: var(--spacing-200); min-height: 300px; - color: var(--color-text-muted); -} - -.loading-spinner { - width: 40px; - height: 40px; - border: 3px solid var(--s2-gray-75); - border-top: 3px solid var(--color-primary); - border-radius: 50%; - animation: spin 1s linear infinite; - margin-bottom: var(--spacing-300); -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } + color: var(--s2-gray-700); } /* ========== Messages ========== */ .message { padding: var(--spacing-200) var(--spacing-300); + margin-bottom: var(--spacing-200); border-radius: var(--s2-radius-100); - margin-bottom: var(--spacing-300); - font-size: var(--s2-font-size-100); + font-size: var(--s2-body-s-size); line-height: 1.5; } .message.success { - background: #e6f4ea; - border: 1px solid #b7dfbf; - color: #1a6b2a; + background: var(--s2-green-100); + color: var(--s2-green-900); } .message.error { - background: #fce8e6; - border: 1px solid #f5c6c2; - color: #b3261e; + background: var(--s2-red-100); + color: var(--s2-red-900); } .message.info { - background: var(--s2-red-200); - border: 1px solid #b8d4fe; - color: var(--s2-red-1200); + background: var(--s2-gray-100); + color: var(--s2-gray-800); } -/* ========== Result / Status States ========== */ -.result-container { +/* ========== Status Pages (approved, rejected, error, unauthorized, no-request) ========== */ +.status-page { text-align: center; - padding: var(--spacing-600) var(--spacing-400); - max-width: 500px; - margin: 0 auto; + max-width: 520px; + margin: var(--spacing-600) auto 0; } -.result-icon { - font-size: 48px; +.status-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 50%; margin-bottom: var(--spacing-300); } -.result-container h2 { - margin: 0 0 var(--spacing-200) 0; - font-size: var(--s2-heading-size-500); - font-weight: 700; -} - -.result-container.approved h2 { - color: var(--color-success); +.status-icon svg { + width: 24px; + height: 24px; } -.result-container.rejected h2 { - color: var(--color-danger); +.status-icon--success { + background: var(--s2-green-100); + color: var(--s2-green-900); } -.result-container > p { - color: var(--color-text); - margin: 0 0 var(--spacing-400) 0; +.status-icon--error { + background: var(--s2-red-100); + color: var(--s2-red-900); } -.result-note { - margin-top: var(--spacing-400); - font-size: 13px; - color: var(--color-text-muted); +.status-icon--warning { + background: var(--s2-orange-100); + color: var(--s2-orange-900); } -.result-actions { - margin-top: var(--spacing-400); +.status-icon--neutral { + background: var(--s2-gray-200); + color: var(--s2-gray-700); } -/* ========== Error / Status Pages ========== */ -.error-container { - text-align: center; - padding: var(--spacing-600) var(--spacing-400); +.status-heading { + margin: 0 0 var(--spacing-100) 0; + font-size: var(--s2-heading-s-size); + font-weight: 700; + color: var(--s2-gray-900); } -.error-icon { - font-size: 48px; - margin-bottom: var(--spacing-300); +.status-heading--success { + color: var(--s2-green-900); } -.error-container h2 { - margin: 0 0 var(--spacing-300) 0; - font-size: var(--s2-heading-size-500); - font-weight: 700; - color: var(--color-danger); +.status-heading--error { + color: var(--s2-red-900); } -.error-help { - color: var(--color-text-muted); - max-width: 400px; - margin: var(--spacing-300) auto 0; - font-size: var(--s2-font-size-100); +.status-body { + margin: 0 0 var(--spacing-400) 0; + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); line-height: 1.5; } -/* ========== Info Card ========== */ -.info-card { - background: var(--color-bg-light); - border-radius: var(--s2-radius-100); - padding: var(--spacing-300); +.status-page .review-card { text-align: left; - margin-bottom: var(--spacing-400); -} - -.info-row { - display: flex; - align-items: flex-start; - gap: var(--spacing-200); - margin-bottom: var(--spacing-200); - font-size: var(--s2-font-size-100); } -.info-row:last-child { +.status-page .back-link { + margin-top: var(--spacing-200); margin-bottom: 0; } -.info-row .label { - color: var(--color-text-muted); - min-width: 80px; - flex-shrink: 0; -} - -.info-row code { - background: var(--color-bg-code); - padding: var(--spacing-75) var(--spacing-100); - border-radius: var(--s2-radius-75); - font-family: var(--fixed-font-family); - font-size: 13px; - word-break: break-all; -} - -.info-row a { - color: var(--color-link); - word-break: break-all; -} - -.info-row a:hover { - color: var(--color-link-hover); - text-decoration: underline; -} - /* ========== Review Container ========== */ .review-container { - max-width: 900px; - margin: 0 auto; + margin: 0 var(--spacing-600); } .review-header { margin-bottom: var(--spacing-400); - padding-bottom: var(--spacing-300); - border-bottom: 2px solid var(--color-border); } -.review-header h1 { - margin: 0 0 var(--spacing-100) 0; - font-size: var(--s2-heading-size-700); +.review-header h2 { + margin: 0 0 var(--spacing-75) 0; + font-size: var(--s2-heading-s-size); font-weight: 700; - color: var(--color-text); + color: var(--s2-gray-900); } .review-subtitle { margin: 0; - color: var(--color-text-muted); - font-size: var(--s2-font-size-200); + color: var(--s2-gray-600); + font-size: var(--s2-body-s-size); + line-height: 1.5; } -/* ========== Request Details ========== */ -.request-details { - background: var(--color-bg-light); - border-radius: var(--s2-radius-100); - padding: var(--spacing-400); - margin-bottom: var(--spacing-400); +/* ========== Review Card (shared card treatment for all detail sections) ========== */ +.review-card { + background: var(--s2-gray-50); + border: 1px solid var(--s2-gray-200); + border-radius: var(--s2-radius-200); + padding: var(--spacing-300) var(--spacing-400); + margin-bottom: var(--spacing-300); } -.request-details h3 { - margin: 0 0 var(--spacing-300) 0; - font-size: var(--s2-font-size-200); +.review-card-title { + margin: 0 0 var(--spacing-200) 0; + font-size: var(--s2-body-m-size); font-weight: 700; - color: var(--color-text); + color: var(--s2-gray-900); } -.details-grid { - display: grid; - grid-template-columns: 1fr; - gap: var(--spacing-300); +.review-card-body { + margin: 0 0 var(--spacing-200) 0; + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); + line-height: 1.5; } -.detail-item { +/* ========== Detail List (dl/dt/dd inside cards) ========== */ +.detail-list { + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} + +.detail-row { display: flex; - flex-direction: row; align-items: baseline; gap: var(--spacing-200); + padding: var(--spacing-150) 0; + border-bottom: 1px solid var(--s2-gray-200); } -.detail-item.full-width { - grid-column: 1 / -1; +.detail-row:last-child { + border-bottom: none; + padding-bottom: 0; } -.detail-label { - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.02em; - color: var(--color-text-muted); - min-width: 120px; - flex-shrink: 0; +.detail-row:first-child { + padding-top: 0; } -.detail-value { - font-size: var(--s2-font-size-100); - color: var(--color-text); +.detail-row dt { + flex-shrink: 0; + width: 120px; + font-size: var(--s2-body-s-size); + font-weight: 600; + color: var(--s2-gray-600); } -.detail-value.comment { - padding: var(--spacing-200); - border-radius: var(--s2-radius-100); - font-style: italic; +.detail-row dd { margin: 0; -} - -.detail-value.link { - color: var(--color-link); - text-decoration: none; -} - -.detail-value.link:hover { - color: var(--color-link-hover); - text-decoration: underline; -} - -code.detail-value { - background: var(--color-bg-code); - padding: 6px 10px; - border-radius: var(--s2-radius-75); - font-family: var(--fixed-font-family); - font-size: 13px; + font-size: var(--s2-body-s-size); + color: var(--s2-gray-900); word-break: break-all; } -/* ========== Diff Section ========== */ -.diff-section { - margin-bottom: var(--spacing-400); -} - -.diff-section h3 { - margin: 0 0 var(--spacing-100) 0; - font-size: var(--s2-font-size-200); - font-weight: 700; - color: var(--color-text); +.detail-row dd code { + font-family: var(--fixed-font-family); + font-size: var(--s2-body-s-size); + background: var(--s2-gray-100); + padding: var(--spacing-75) var(--spacing-200); + border-radius: var(--s2-radius-75); } -.diff-description { - margin: 0 0 var(--spacing-200) 0; - font-size: var(--s2-font-size-100); - color: var(--color-text-muted); +.detail-comment { + font-size: var(--s2-body-s-size); + font-style: italic; + background: var(--s2-gray-100); + padding: var(--spacing-75) var(--spacing-200); + border-radius: var(--s2-radius-75); + color: var(--s2-gray-800); } -.diff-iframe-container { - border: 1px solid var(--color-border); - border-radius: var(--s2-radius-100); - overflow: hidden; - background: white; +/* ========== SMART Checklist ========== */ +.smart-checklist { + list-style: none; + margin: 0 0 var(--spacing-300) 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-75); } -.diff-iframe { - width: 100%; - height: 500px; - border: none; +.smart-checklist li { + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); + display: flex; + align-items: center; + gap: var(--spacing-100); } -.open-diff-link { +.smart-checklist li strong { display: inline-flex; align-items: center; - gap: var(--spacing-75); - margin-top: var(--spacing-100); - font-size: 13px; - color: var(--color-link); - text-decoration: none; + justify-content: center; + width: 22px; + height: 22px; + border-radius: var(--s2-radius-75); + background: var(--s2-gray-200); + color: var(--s2-gray-800); + font-size: 11px; font-weight: 700; + flex-shrink: 0; } -.open-diff-link:hover { - color: var(--color-link-hover); - text-decoration: underline; -} - -/* ========== Decision Section ========== */ -.decision-section { - background: #fff; - border: 2px solid var(--color-border); - border-radius: var(--s2-radius-100); - padding: var(--spacing-400); -} - -.decision-section h3 { - margin: 0 0 var(--spacing-300) 0; - font-size: var(--s2-font-size-300); - font-weight: 700; - color: var(--color-text); +/* ========== Decision Card ========== */ +.review-card--decision { + background: var(--s2-gray-25, #fff); + border: 1px solid var(--s2-gray-300); } .warning-banner { display: flex; - align-items: center; + align-items: flex-start; gap: var(--spacing-100); - background: #fff8e1; - border: 1px solid #ffe082; - border-radius: var(--s2-radius-100); - padding: var(--spacing-200) var(--spacing-300); + padding: var(--spacing-200); margin-bottom: var(--spacing-300); - font-size: var(--s2-font-size-100); - color: #8d6e00; + font-size: var(--s2-body-s-size); + color: var(--s2-orange-900); + background: var(--s2-orange-100); + border-radius: var(--s2-radius-100); + line-height: 1.5; } .warning-icon { - font-size: 18px; + width: 18px; + height: 18px; + flex-shrink: 0; + color: var(--s2-orange-800); + margin-top: 1px; } .reviewer-info { - font-size: var(--s2-font-size-100); - color: var(--color-text-muted); - margin: 0 0 var(--spacing-400) 0; + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); + margin: 0 0 var(--spacing-300) 0; } .reviewer-info strong { - color: var(--color-text); + color: var(--s2-gray-900); } -.decision-buttons { - margin-bottom: var(--spacing-400); -} - -/* ========== Approvers Section ========== */ -.approvers-section { - background: var(--s2-red-200); - border: 1px solid #b8d4fe; - border-left: 4px solid var(--color-primary); - border-radius: var(--s2-radius-100); - padding: var(--spacing-300); - margin-bottom: var(--spacing-400); -} - -.approvers-header { +.decision-actions { display: flex; - align-items: center; - gap: var(--spacing-100); - margin-bottom: var(--spacing-200); -} - -.approvers-icon { - font-size: 18px; -} - -.approvers-title { - font-weight: 700; - font-size: var(--s2-font-size-100); - color: var(--color-text); -} - -.approvers-list { - margin: 0; - padding-left: var(--spacing-400); - font-size: var(--s2-font-size-100); - color: var(--color-text); - list-style-type: none; -} - -.approvers-list li { - margin-bottom: var(--spacing-75); -} - -.approvers-list li:last-child { - margin-bottom: 0; -} - -.approvers-note { - margin: var(--spacing-200) 0 0 0; - font-size: 12px; - color: var(--color-text-muted); + margin-bottom: var(--spacing-400); } /* ========== Form Elements ========== */ @@ -554,120 +452,73 @@ code.detail-value { .form-group label { display: block; - font-size: var(--s2-font-size-100); - font-weight: 700; - margin-bottom: 6px; - color: var(--color-text); + font-size: var(--s2-body-s-size); + font-weight: 600; + margin-bottom: var(--spacing-75); + color: var(--s2-gray-800); } .form-group label .required { - color: var(--color-danger); + color: var(--s2-red-900); } -.form-group textarea { - width: 100%; - padding: 10px var(--spacing-200); - border: 1px solid var(--color-border); - border-radius: var(--s2-radius-100); - font-size: var(--s2-font-size-100); - font-family: var(--body-font-family); - box-sizing: border-box; - resize: vertical; - color: var(--color-text); +/* ========== Small action button ========== */ +.pw-action-sm::part(base) { + padding: var(--spacing-75) var(--spacing-200); + font-size: var(--s2-body-s-size); } -.form-group textarea:focus { - outline: none; - border-color: var(--color-primary); - box-shadow: 0 0 0 3px rgba(59, 99, 251, 0.15); +/* ========== Reject Section ========== */ +.reject-section { + border-top: 1px solid var(--s2-gray-200); + padding-top: var(--spacing-200); + margin-top: 0; } -/* ========== Buttons ========== */ -button, -.btn-primary { +.reject-toggle { + display: flex; + align-items: center; + gap: var(--spacing-75); cursor: pointer; - font-family: var(--body-font-family); - transition: all 0.2s ease; - text-decoration: none; - display: inline-block; -} - -.btn-primary { - background: var(--color-primary); - color: white; - border: none; - padding: var(--spacing-200) var(--spacing-400); - border-radius: var(--s2-radius-300); - font-weight: 700; - font-size: 15px; - line-height: 16px; -} - -.btn-primary:hover:not(:disabled) { - background: var(--color-primary-hover); -} - -.btn-primary:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.btn-approve { - background: var(--color-success); - color: white; - border: none; - padding: 14px var(--spacing-500); - border-radius: var(--s2-radius-300); - font-weight: 700; - font-size: var(--s2-font-size-200); - width: 100%; -} - -.btn-approve:hover:not(:disabled) { - background: var(--color-primary-hover); + font-size: var(--s2-body-s-size); + font-weight: 600; + color: var(--s2-gray-700); + padding: var(--spacing-75) 0; + user-select: none; + list-style: none; } -.btn-approve:disabled, -.btn-reject:disabled { - opacity: 0.5; - cursor: not-allowed; +.reject-toggle::-webkit-details-marker { + display: none; } -.btn-reject { - background: var(--color-danger); - color: white; - border: none; - padding: var(--spacing-200) var(--spacing-400); - border-radius: var(--s2-radius-300); - font-weight: 700; - font-size: var(--s2-font-size-100); -} - -.btn-reject:hover:not(:disabled) { - background: #d42a1a; +.reject-toggle:hover { + color: var(--s2-gray-900); } -/* ========== Reject Section ========== */ -.reject-section { - border-top: 1px solid var(--color-border); - padding-top: var(--spacing-300); - margin-top: var(--spacing-100); +.reject-toggle:focus-visible { + outline: 2px solid var(--pw-accent); + outline-offset: 2px; } -.reject-toggle { - cursor: pointer; - font-size: var(--s2-font-size-100); - color: var(--color-danger); - padding: var(--spacing-100) 0; - user-select: none; +.reject-chevron { + width: 12px; + height: 12px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; + transition: transform 0.15s ease; } -.reject-toggle:hover { - text-decoration: underline; +.reject-section[open] > .reject-toggle .reject-chevron { + transform: rotate(90deg); } .reject-form { - padding-top: var(--spacing-300); + padding-top: var(--spacing-200); } /* ========== Back Link ========== */ @@ -675,65 +526,70 @@ button, display: inline-flex; align-items: center; gap: var(--spacing-75); - color: var(--color-link); + color: var(--s2-gray-700); text-decoration: none; - font-size: var(--s2-font-size-100); - font-weight: 700; + font-size: var(--s2-body-s-size); + font-weight: 600; margin-bottom: var(--spacing-300); + padding: var(--spacing-75) var(--spacing-100); + border-radius: var(--s2-radius-100); + transition: color 0.15s ease, background 0.15s ease; + background: var(--s2-gray-100); } .back-link:hover { - color: var(--color-link-hover); - text-decoration: underline; + color: var(--s2-gray-900); + background: var(--s2-gray-200); } -.back-to-inbox { - margin-top: var(--spacing-400); +.back-link:focus-visible { + outline: 2px solid var(--pw-accent); + outline-offset: 2px; } -.result-actions .back-link { - display: block; - margin-top: var(--spacing-300); +.back-chevron { + width: 12px; + height: 12px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; } + /* ========== Inbox ========== */ .inbox-container { - max-width: 900px; - margin: 0 auto; + margin: 0 var(--spacing-600); } .inbox-header { margin-bottom: var(--spacing-400); padding-bottom: var(--spacing-300); - border-bottom: 2px solid var(--color-border); + border-bottom: 2px solid var(--s2-gray-200); display: flex; - align-items: flex-start; + align-items: flex-end; justify-content: space-between; } -.inbox-header h1 { - margin: 0 0 var(--spacing-75) 0; - font-size: var(--s2-heading-size-700); - font-weight: 700; - color: var(--color-text); -} - .inbox-subtitle { margin: 0; - font-size: var(--s2-font-size-100); - color: var(--color-text-muted); + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); } .inbox-subtitle strong { - color: var(--color-text); + color: var(--s2-gray-800); } .inbox-count { - background: var(--color-primary); - color: white; - padding: var(--spacing-75) 14px; - border-radius: 20px; - font-size: var(--s2-font-size-100); + background: var(--s2-gray-75); + color: var(--s2-gray-900); + border: 1px solid var(--s2-gray-300); + padding: var(--spacing-75) var(--spacing-200); + border-radius: var(--s2-radius-300); + font-size: var(--s2-body-s-size); font-weight: 700; white-space: nowrap; margin-top: var(--spacing-75); @@ -742,162 +598,135 @@ button, /* ---- Inbox Actions Bar ---- */ .inbox-actions-bar { display: flex; - justify-content: flex-end; - margin-bottom: var(--spacing-300); -} - -.btn-approve-all { - background: var(--color-success); - color: white; - border: none; - padding: 10px var(--spacing-400); - border-radius: var(--s2-radius-300); - font-weight: 700; - font-size: var(--s2-font-size-100); - cursor: pointer; - transition: all 0.2s ease; -} - -.btn-approve-all:hover:not(:disabled) { - background: var(--color-primary-hover); -} - -.btn-approve-all:disabled { - opacity: 0.5; - cursor: not-allowed; + justify-content: flex-start; + margin-top: var(--spacing-400); } /* ---- Inbox List ---- */ .inbox-list { display: flex; flex-direction: column; - gap: var(--spacing-200); } +/* Collapsible inbox item (
) */ .inbox-item { - background: var(--color-bg-light); - border: 1px solid var(--color-border); - border-radius: var(--s2-radius-100); - padding: var(--spacing-300) var(--spacing-400); + border-bottom: 1px solid var(--s2-gray-200); +} + +.inbox-item:last-child { + border-bottom: none; +} + +.inbox-item-header { display: flex; align-items: center; - justify-content: space-between; - gap: var(--spacing-300); - transition: border-color 0.15s ease, background-color 0.15s ease; + gap: var(--spacing-100); + padding: var(--spacing-200) 0; + cursor: pointer; + list-style: none; + user-select: none; } -.inbox-item:hover { - border-color: var(--s2-blue-700); - background: rgb(228 240 255); +.inbox-item-header::-webkit-details-marker { + display: none; } -.inbox-item-info { - flex: 1; - min-width: 0; +/* Chevron */ +.inbox-item-chevron { + flex-shrink: 0; + width: 12px; + height: 12px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + color: var(--s2-gray-600); + transition: transform 0.15s ease; +} + +.inbox-item[open] > .inbox-item-header .inbox-item-chevron { + transform: rotate(90deg); } .inbox-item-path { + flex: 1; + min-width: 0; font-weight: 700; - font-size: var(--s2-font-size-100); - color: var(--color-text); + font-size: var(--s2-body-s-size); + color: var(--s2-gray-800); word-break: break-all; } -.inbox-item-meta { - font-size: 12px; - color: var(--color-text-muted); - margin-top: var(--spacing-75); -} - -.inbox-item-comment { - font-size: 12px; - color: var(--color-text-muted); - margin-top: var(--spacing-75); - font-style: italic; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .inbox-item-actions { display: flex; + align-items: center; gap: var(--spacing-100); flex-shrink: 0; + margin-left: auto; } -/* ---- Small Buttons (inbox actions) ---- */ -.btn-sm { - padding: var(--spacing-100) 14px; - font-size: 13px; - border-radius: var(--s2-radius-75); - border: none; - font-weight: 700; - cursor: pointer; - text-decoration: none; +/* ---- Action Links (Diff, Review, Preview) ---- */ +.action-link { display: inline-flex; align-items: center; - transition: all 0.15s ease; - font-family: var(--body-font-family); -} - -.btn-diff { - background: var(--color-bg-code); - color: var(--color-text); -} - -.btn-diff:hover { - background: var(--color-border); -} - -.btn-review-link { - background: var(--color-bg-code); - color: var(--color-link); -} - -.btn-review-link:hover { - background: var(--color-border); -} - -.btn-approve-sm { - background: var(--color-success); - color: white; + gap: var(--spacing-50); + padding: var(--spacing-75) var(--spacing-200); + font-size: var(--s2-body-s-size); + font-weight: 600; + color: var(--pw-accent); + text-decoration: none; + border-radius: var(--s2-radius-100); + transition: background 0.15s ease, color 0.15s ease; } -.btn-approve-sm:hover:not(:disabled) { - background: var(--color-primary-hover); +.action-link:hover { + background: var(--s2-gray-100); + color: var(--pw-accent-hover); } -.btn-approve-sm:disabled { - opacity: 0.5; - cursor: not-allowed; +.action-link:focus-visible { + outline: 2px solid var(--pw-accent); + outline-offset: 2px; } -.btn-resend-sm { - background: var(--s2-red-200); - color: #b3261e; +.action-icon { + width: 14px; + height: 14px; + fill: currentColor; + flex-shrink: 0; } -.btn-resend-sm:hover:not(:disabled) { - background: #f5c6c2; +/* ---- Expanded details panel ---- */ +.inbox-item-details { + padding: 0 0 var(--spacing-200) calc(18px + var(--spacing-100)); + display: flex; + flex-direction: column; + gap: var(--spacing-75); } -.btn-resend-sm:disabled { - opacity: 0.5; - cursor: not-allowed; +.inbox-item-detail-row { + display: flex; + gap: var(--spacing-200); + font-size: var(--s2-body-s-size); + line-height: 1.5; } -.btn-withdraw-sm { - background: #fce8e6; - color: #b3261e; +.detail-label { + flex-shrink: 0; + width: 100px; + color: var(--s2-gray-600); + font-weight: 600; } -.btn-withdraw-sm:hover:not(:disabled) { - background: #f5c6c2; +.detail-value { + color: var(--s2-gray-800); } -.btn-withdraw-sm:disabled { - opacity: 0.5; - cursor: not-allowed; +.status-badge { + font-size: var(--s2-body-s-size); + font-weight: 700; + color: var(--s2-gray-700); } /* ---- Inbox Empty State ---- */ @@ -906,20 +735,16 @@ button, padding: var(--spacing-800) var(--spacing-400); } -.inbox-empty-icon { - font-size: 48px; - margin-bottom: var(--spacing-300); -} - +/* stylelint-disable-next-line no-descending-specificity */ .inbox-empty h2 { margin: 0 0 var(--spacing-100) 0; - font-size: var(--s2-heading-size-500); + font-size: var(--s2-heading-s-size); font-weight: 700; - color: var(--color-text); + color: var(--s2-gray-800); } .inbox-empty p { margin: 0; - color: var(--color-text-muted); - font-size: var(--s2-font-size-100); + color: var(--s2-gray-700); + font-size: var(--s2-body-s-size); } diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.html b/tools/apps/publish-requests-inbox/publish-requests-inbox.html index 70f0bf8..10117cc 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.html +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.html @@ -7,7 +7,8 @@ - + + @@ -15,6 +16,6 @@ - + diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index a5b5f61..ed8e358 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -18,13 +18,35 @@ import { resendPublishRequest, fetchSiteConfig, getLiveHostFromConfig, + fetchAccentSettings, } from './api.js'; -// Super Lite components -import 'https://da.live/nx/public/sl/components.js'; - -// Application styles -import loadStyle from '../../scripts/utils/styles.js'; +// Super Lite (sl-*) — Spectrum-aligned controls for DA; pairs with S2 tokens in CSS. +// NX style pipeline matches other da.live shell apps (e.g. MSM): nexter.js loadStyle + getStyle. +const NX = 'https://da.live/nx'; +let nexter = null; +let sl = null; +let styles = null; +let buttons = null; +try { + const [{ default: getStyle }, { loadStyle }] = await Promise.all([ + import(`${NX}/utils/styles.js`), + import(`${NX}/scripts/nexter.js`), + ]); + await Promise.all([ + loadStyle(`${NX}/styles/nexter.css`), + loadStyle(`${NX}/public/sl/styles.css`), + ]); + await import(`${NX}/public/sl/components.js`); + [nexter, sl, styles, buttons] = await Promise.all([ + getStyle(`${NX}/styles/nexter.css`), + getStyle(`${NX}/public/sl/styles.css`), + getStyle(import.meta.url), + getStyle(`${NX}/styles/buttons.css`), + ]); +} catch (e) { + console.warn('Failed to load styles:', e); +} // RUM helper – safely fires a checkpoint if the RUM script is loaded function sampleRUM(checkpoint, data = {}) { @@ -33,20 +55,29 @@ function sampleRUM(checkpoint, data = {}) { } catch { /* noop */ } } -const styles = await loadStyle(import.meta.url); +/** Parse `org/site` or `/org/site/` into `{ org, site }`. Returns null if invalid. */ +function parseOrgSitePath(raw) { + const normalized = (raw || '').trim().replace(/^\/+/, '').replace(/\/+$/, ''); + if (!normalized) return null; + const parts = normalized.split('/').filter((segment) => segment.length > 0); + if (parts.length !== 2) return null; + const [org, site] = parts; + if (!org || !site) return null; + return { org, site }; +} class PublishRequestsApp extends LitElement { static properties = { context: { attribute: false }, token: { attribute: false }, - // view states: 'loading', 'site-select', 'inbox', 'review', 'approved', + // view states: 'loading', 'idle', 'inbox', 'review', 'approved', // 'rejected', 'error', 'unauthorized', 'no-request' _state: { state: true }, _isProcessing: { state: true }, _message: { state: true }, _userEmail: { state: true }, _needsEmail: { state: true }, - // Request data from URL params + // Request data _org: { state: true }, _site: { state: true }, _path: { state: true }, @@ -61,8 +92,11 @@ class PublishRequestsApp extends LitElement { _approveAllProcessing: { state: true }, // My-requests mode: tracks per-path action ('resending' | 'withdrawing') _myRequestActions: { state: true }, - // Site selection form + // Toolbar _siteSelectLoading: { state: true }, + _orgSiteValue: { state: true }, + // Inline reject error + _rejectError: { state: true }, }; constructor() { @@ -85,11 +119,12 @@ class PublishRequestsApp extends LitElement { this._requester = false; this._myRequestActions = new Map(); this._siteSelectLoading = false; + this._orgSiteValue = ''; } connectedCallback() { super.connectedCallback(); - this.shadowRoot.adoptedStyleSheets = [styles]; + this.shadowRoot.adoptedStyleSheets = [nexter, sl, buttons, styles].filter(Boolean); this.init(); } @@ -139,32 +174,36 @@ class PublishRequestsApp extends LitElement { return `${this.appBaseUrl}?${params.toString()}`; } - /** - * Navigate the top-level (parent) browser window to the given URL. - * Since this app runs inside an iframe, we must use window.top to - * change the main browser URL. - */ - navigateTop(url, e) { - if (e) e.preventDefault(); + async loadSiteSettings(org, site) { try { - window.top.location.href = url; + const [siteConfig, accentSettings] = await Promise.all([ + fetchSiteConfig(org, site), + fetchAccentSettings(org, site, this.token), + ]); + this._liveHost = getLiveHostFromConfig(org, site, siteConfig); + if (accentSettings.accentColor) { + this.style.setProperty('--pw-accent', accentSettings.accentColor); + } + if (accentSettings.accentColorHover) { + this.style.setProperty('--pw-accent-hover', accentSettings.accentColorHover); + } } catch { - // Cross-origin fallback - window.location.href = url; + this._liveHost = null; } } + updateUrl(params) { + try { + const qs = new URLSearchParams(params).toString(); + window.history.replaceState(null, '', `${window.location.pathname}?${qs}`); + } catch { /* cross-origin iframe — ignore */ } + } + async init() { - // Fetch user email from Adobe IMS profile this._userEmail = await getUserEmail(this.token); this._needsEmail = !this._userEmail; - // Parse URL parameters const urlParams = new URLSearchParams(window.location.search); - - // Only use explicit URL params for org/repo — do NOT fall back to - // this.context because that reflects the *current* DA site the app is - // hosted on, not the site the user wants to manage. this._org = urlParams.get('org') || ''; this._site = urlParams.get('site') || ''; this._path = urlParams.get('path') || ''; @@ -173,19 +212,16 @@ class PublishRequestsApp extends LitElement { this._comment = urlParams.get('comment') || ''; this._requester = urlParams.get('requester') || false; - // If org/repo are missing, show the site selection form + if (this._org && this._site) { + this._orgSiteValue = `/${this._org}/${this._site}`; + } + if (!this._org || !this._site) { - this._state = 'site-select'; + this._state = 'idle'; return; } - // Fetch CDN config from admin.hlx.page to resolve live host (custom domains, etc.) - try { - const config = await fetchSiteConfig(this._org, this._site); - this._liveHost = getLiveHostFromConfig(this._org, this._site, config); - } catch { - this._liveHost = null; // fall back to default in liveUrl getter - } + await this.loadSiteSettings(this._org, this._site); // Sample RUM enhancer if the RUM script is loaded window.hlx?.rum?.sampleRUM?.enhance?.(); @@ -301,25 +337,26 @@ class PublishRequestsApp extends LitElement { this._state = 'review'; } - // ======== Site selection handler ======== + // ======== Toolbar handler — loads inbox inline ======== async handleSiteSelect(e) { - e.preventDefault(); + if (e) e.preventDefault(); this._message = null; - const form = this.shadowRoot.querySelector('#site-select-form'); - const formData = new FormData(form); - const org = formData.get('org')?.trim(); - const site = formData.get('site')?.trim(); - - if (!org || !site) { - this._message = { type: 'error', text: 'Please provide both Organization and Site.' }; + const input = this.shadowRoot.querySelector('#org-site'); + const orgSite = (input?.value ?? '').trim(); + const parsed = parseOrgSitePath(orgSite); + if (!parsed) { + this._message = { + type: 'error', + text: 'Enter organization and site as /org/site (for example aemsites/da-blog-tools).', + }; return; } + const { org, site } = parsed; this._siteSelectLoading = true; - // Fetch user email first (needed for permission check) if (!this._userEmail) { this._userEmail = await getUserEmail(this.token); this._needsEmail = !this._userEmail; @@ -331,24 +368,79 @@ class PublishRequestsApp extends LitElement { return; } - // Validate access by fetching requests for this org/site - try { - const isRequesterMode = !!this._requester; - await (isRequesterMode - ? getAllPendingRequestsByRequester(org, site, this._userEmail, this.token) - : getAllPendingRequestsForUser(org, site, this._userEmail, this.token)); - - // Access validated — navigate the top-level window with org/site in the URL - // so the params persist in the browser address bar across page refreshes. - const params = new URLSearchParams(); - params.set('org', org); - params.set('site', site); - if (isRequesterMode) params.set('requester', 'true'); - const targetUrl = `${this.appBaseUrl}?${params.toString()}`; - this.navigateTop(targetUrl); - } catch (error) { - this._siteSelectLoading = false; - this._message = { type: 'error', text: error.message || `Unable to access site "${org}/${site}". Please check the organization and site names.` }; + this._org = org; + this._site = site; + this._orgSiteValue = `/${org}/${site}`; + this._path = ''; + this._pendingRequests = []; + + await this.loadSiteSettings(org, site); + + const urlParams = { org, site }; + if (this._requester) urlParams.requester = 'true'; + this.updateUrl(urlParams); + + // Load inbox inline + if (this._requester) { + await this.initMyRequests(); + } else { + await this.initInbox(); + } + + this._siteSelectLoading = false; + } + + // ======== Inline review — opens detail view without page navigation ======== + + async handleInlineReview(request) { + this._path = request.path; + this._authorEmail = request.requester || request.authorEmail || ''; + this._comment = request.comment || ''; + const path = request.path?.replace(/\/index$/, '') || ''; + this._previewUrl = `https://main--${this._site}--${this._org}.aem.page${path}`; + this._message = null; + this._state = 'loading'; + + const urlParams = { + org: this._org, + site: this._site, + path: request.path, + preview: this._previewUrl, + }; + if (request.requester) urlParams.author = request.requester; + this.updateUrl(urlParams); + + await this.initReview(); + } + + // ======== Back to inbox — inline, no navigation ======== + + backToInbox(e) { + if (e) e.preventDefault(); + const reviewedPath = this._path; + this._path = ''; + this._authorEmail = ''; + this._comment = ''; + this._previewUrl = ''; + this._message = null; + this._rejectError = null; + this._isProcessing = false; + + // Remove the just-processed request from the cached list + if (reviewedPath && (this._state === 'approved' || this._state === 'rejected')) { + this._pendingRequests = this._pendingRequests.filter((r) => r.path !== reviewedPath); + } + + const urlParams = { org: this._org, site: this._site }; + if (this._requester) urlParams.requester = 'true'; + this.updateUrl(urlParams); + + if (this._pendingRequests.length > 0) { + this._state = this._requester ? 'my-requests' : 'inbox'; + } else if (this._requester) { + this.initMyRequests(); + } else { + this.initInbox(); } } @@ -386,11 +478,9 @@ class PublishRequestsApp extends LitElement { }, this.token, ); - this._message = notifyResult.success - ? { type: 'success', text: 'Content published successfully!' } - : { type: 'info', text: `Content published. Author notification failed: ${notifyResult.error}` }; - } else { - this._message = { type: 'success', text: 'Content published successfully!' }; + if (!notifyResult.success) { + this._message = { type: 'info', text: `Author notification failed: ${notifyResult.error}` }; + } } } else { this._message = { type: 'error', text: result.error }; @@ -400,23 +490,21 @@ class PublishRequestsApp extends LitElement { } } - async handleReject(e) { - e.preventDefault(); - + async handleReject() { if (this._needsEmail) { - this._message = { type: 'error', text: 'Unable to determine your email. Please log in again.' }; + this._rejectError = 'Unable to determine your email. Please log in again.'; return; } - const form = this.shadowRoot.querySelector('#reject-form'); - const formData = new FormData(form); - const reason = formData.get('reason')?.trim(); + const textarea = this.shadowRoot.querySelector('#reason'); + const reason = (textarea?.value ?? '').trim(); if (!reason) { - this._message = { type: 'error', text: 'Please provide a reason for rejection.' }; + this._rejectError = 'Please provide a reason for rejection.'; return; } + this._rejectError = null; this._isProcessing = true; this._message = null; @@ -436,10 +524,8 @@ class PublishRequestsApp extends LitElement { this._isProcessing = false; if (result.success) { - // Remove the pending request from the requests sheet await removePublishRequest(this._org, this._site, this._path, this.token); this._state = 'rejected'; - this._message = { type: 'info', text: 'Rejection notification sent to author.' }; } else { this._message = { type: 'error', text: result.error }; } @@ -645,9 +731,9 @@ class PublishRequestsApp extends LitElement { renderLoading() { return html` -
-
-

Loading...

+
+ +

Loading…

`; } @@ -657,65 +743,70 @@ class PublishRequestsApp extends LitElement { return html`
${this._message.text}
`; } - // ======== Site Selection render ======== + // ======== Persistent toolbar (always visible) ======== - renderSiteSelect() { + renderToolbar() { const isRequesterMode = !!this._requester; const title = isRequesterMode ? 'My Publish Requests' : 'Publish Request Inbox'; - const subtitle = isRequesterMode - ? 'Select a DA site to view your pending publish requests.' - : 'Select a DA site to view pending publish requests for approval.'; + const primaryLabel = isRequesterMode ? 'View my requests' : 'View publish requests'; return html`
-

${title}

-

${subtitle}

+

${title}

- ${this._userEmail - ? html`

Logged in as: ${this._userEmail}

` - : nothing} - - ${this.renderMessage()} - -
-
- - +
+ + aria-label="Organization and site, format org slash site" + .value=${this._orgSiteValue} + @keydown=${(e) => { if (e.key === 'Enter') this.handleSiteSelect(); }} + >
- -
- - -
- - - + ${this._siteSelectLoading ? 'Loading…' : primaryLabel} + +
`; } + renderContent() { + switch (this._state) { + case 'loading': + return this.renderLoading(); + case 'idle': + return nothing; + case 'error': + return this.renderError(); + case 'unauthorized': + return this.renderUnauthorized(); + case 'no-request': + return this.renderNoRequest(); + case 'inbox': + return this.renderInbox(); + case 'my-requests': + return this.renderMyRequests(); + case 'approved': + return this.renderApproved(); + case 'rejected': + return this.renderRejected(); + case 'review': + return this.renderReview(); + default: + return nothing; + } + } + // ======== Inbox renders ======== renderInbox() { @@ -723,7 +814,6 @@ class PublishRequestsApp extends LitElement {
-

Publish Request Inbox

Logged in as ${this._userEmail}

${this._pendingRequests.length > 0 @@ -731,8 +821,6 @@ class PublishRequestsApp extends LitElement { : nothing}
- ${this.renderMessage()} - ${this._pendingRequests.length === 0 ? this.renderInboxEmpty() : this.renderInboxList()} @@ -743,7 +831,6 @@ class PublishRequestsApp extends LitElement { renderInboxEmpty() { return html`
-

No Pending Requests

You have no publish requests waiting for your approval.

@@ -752,20 +839,20 @@ class PublishRequestsApp extends LitElement { renderInboxList() { return html` +
+ ${this._pendingRequests.map((request) => this.renderInboxItem(request))} +
+
- -
- -
- ${this._pendingRequests.map((request) => this.renderInboxItem(request))} +
`; } @@ -774,30 +861,44 @@ class PublishRequestsApp extends LitElement { const isProcessing = this._processingPaths.has(request.path); const reviewUrl = this.getReviewUrl(request); const diffUrl = this.getDiffUrlForPath(request.path); + const requester = request.requester || 'Unknown'; return html` -
-
-
${request.path}
-
- Requested by: ${request.requester || 'Unknown'} +
+ + + ${request.path} + + + + Diff + + { e.preventDefault(); this.handleInlineReview(request); }}> + + Review + + { e.stopPropagation(); this.handleInboxApprove(request); }} + ?disabled=${isProcessing || this._approveAllProcessing} + > + ${isProcessing ? 'Publishing...' : 'Approve & Publish'} + + + +
+
+ Requested by + ${requester}
- ${request.comment - ? html`
"${request.comment}"
` - : nothing} -
-
- Diff ↗ - this.navigateTop(reviewUrl, e)}>Review - + ${request.comment ? html` +
+ Message + ${request.comment} +
+ ` : nothing}
-
+
`; } @@ -808,7 +909,6 @@ class PublishRequestsApp extends LitElement {
-

My Publish Requests

Logged in as ${this._userEmail}

${this._pendingRequests.length > 0 @@ -816,8 +916,6 @@ class PublishRequestsApp extends LitElement { : nothing}
- ${this.renderMessage()} - ${this._pendingRequests.length === 0 ? this.renderMyRequestsEmpty() : this.renderMyRequestsList()} @@ -828,7 +926,6 @@ class PublishRequestsApp extends LitElement { renderMyRequestsEmpty() { return html`
-

No Pending Requests

You have no pending publish requests awaiting approval.

@@ -850,34 +947,44 @@ class PublishRequestsApp extends LitElement { const isBusy = !!action; return html` -
-
-
${request.path}
-
- Status: Pending Approval +
+ + + ${request.path} + + + + Preview + + { e.stopPropagation(); this.handleMyRequestResend(request); }} + ?disabled=${isBusy} + > + ${action === 'resending' ? 'Resending...' : 'Resend'} + + { e.stopPropagation(); this.handleMyRequestWithdraw(request); }} + ?disabled=${isBusy} + > + ${action === 'withdrawing' ? 'Withdrawing...' : 'Withdraw'} + + + +
+
+ Status + Pending Approval
- ${request.comment - ? html`
"${request.comment}"
` - : nothing} -
-
- Preview ↗ - - + ${request.comment ? html` +
+ Message + ${request.comment} +
+ ` : nothing}
-
+ `; } @@ -885,58 +992,69 @@ class PublishRequestsApp extends LitElement { renderUnauthorized() { return html` -
-
-

Not Authorized

- ${this.renderMessage()} -
-
- Content: - ${this._path} -
-
- Logged in as: - ${this._userEmail} -
+
+
+
-

+

Not Authorized

+

You do not have permission to approve or reject this publish request. Please contact the listed approvers if you believe this is an error.

+ +
+
+
+
Content
+
${this._path}
+
+
+
Logged in as
+
${this._userEmail}
+
+
+
`; } renderNoRequest() { return html` -
-
-

No Pending Request

- ${this.renderMessage()} -
-
- Content: - ${this._path} -
+
+
+
-

+

No Pending Request

+

There is no pending publish request for this content. It may have already been approved, rejected, or was never submitted.

- + +
+
+
+
Content
+
${this._path}
+
+
+
+ + this.backToInbox(e)}> + + Back to Inbox +
`; } renderError() { return html` -
-
-

Error

- ${this.renderMessage()} -

+

+
+ +
+

Error

+

This page requires URL parameters to identify the content to review. Please access this page via the link in your approval email.

@@ -946,57 +1064,65 @@ class PublishRequestsApp extends LitElement { renderApproved() { return html` -
-
-

Published!

-

The content has been published successfully.

- -
-
- Content: - ${this._path} -
-
- Live URL: - ${this.liveUrl} -
+
+
+
+

Published!

+

The content has been published successfully.

+ +
+
+
+
Page
+
${this._path}
+
+ +
+
- + this.backToInbox(e)}> + + Back to Inbox +
`; } renderRejected() { return html` -
-
-

Request Rejected

- ${this.renderMessage()} - -
-
- Page URL: - ${this._path} -
-
- Requested By: - ${this._authorEmail} -
+
+
+
+

Request Rejected

+

The author has been notified about the rejection.

+ +
+
+
+
Page
+
${this._path}
+
+
+
Requested by
+
${this._authorEmail}
+
+
+
-

- The author has been notified about the rejection. -

- - + this.backToInbox(e)}> + + Back to Inbox +
`; } @@ -1006,135 +1132,124 @@ class PublishRequestsApp extends LitElement { renderReview() { return html`
- this.navigateTop(this.getInboxUrl(), e)}>Back to Inbox + this.backToInbox(e)}> + + Back to Inbox +
-

Publish Request Review

-

Review requested website content changes for accuracy and compliance with Western Sydney University website standards.

+

Publish Request Review

+

Review the requested content changes for accuracy and compliance before publishing.

-
-

Request Details

-
-
- Page URL: - ${this._path} +
+

Request Details

+
+
+
Page
+
${this._path}
-
- Requested By: - ${this._authorEmail || 'Unknown'} +
+
Requested by
+
${this._authorEmail || 'Unknown'}
- ${this._comment - ? html` -
- Author's Note: -

"${this._comment}"

-
- ` - : nothing} - ${this._previewUrl - ? html` -
- Preview Page URL: - - View Preview ↗ - -
- ` - : nothing} -
-
- -
-

Content Changes

-

- Before publishing, please carefully review the requested changes and existing page version. Have they been SMART?
- S – Streamline Site Structure
- M – Metadata for SEO
- A – Accessibility compliant
- R – Redirects requested
- T – Tested all links
- View Existing Page↗ -

-
- - ${this.renderMessage()} + ${this._comment ? html` +
+
Author's note
+
${this._comment}
+
+ ` : nothing} + ${this._previewUrl ? html` +
+
Preview
+
+ + + View Preview + +
+
+ ` : nothing} + + + +
+

Content Changes

+

Before publishing, please review the requested changes. Have they been SMART?

+
    +
  • S Streamline Site Structure
  • +
  • M Metadata for SEO
  • +
  • A Accessibility compliant
  • +
  • R Redirects requested
  • +
  • T Tested all links
  • +
+ + + View Existing Page + +
-
-

Review Request

+
+

Your Decision

${this._needsEmail ? html`
- - Unable to determine your email from session. Please log in to DA first. + + Unable to determine your email. Please log in to DA first.
` : html` -

Reviewing as: ${this._userEmail}

+

Reviewing as ${this._userEmail}

`} -
- +
-
- Reject this request -
+
{ this._rejectError = null; }}> + + + Reject this request + +
- + >
-
` : nothing} + this.handleReject()} ?disabled=${this._isProcessing || this._needsEmail} > ${this._isProcessing ? 'Sending...' : 'Reject Request'} - - + +
-
+
`; } render() { - switch (this._state) { - case 'loading': - return this.renderLoading(); - case 'site-select': - return this.renderSiteSelect(); - case 'error': - return this.renderError(); - case 'unauthorized': - return this.renderUnauthorized(); - case 'no-request': - return this.renderNoRequest(); - case 'inbox': - return this.renderInbox(); - case 'my-requests': - return this.renderMyRequests(); - case 'approved': - return this.renderApproved(); - case 'rejected': - return this.renderRejected(); - case 'review': - default: - return this.renderReview(); - } + return html` + ${this.renderToolbar()} + ${this.renderMessage()} +
+ ${this.renderContent()} +
+ `; } } diff --git a/tools/plugins/request-for-publish/README.md b/tools/plugins/request-for-publish/README.md index d8e81a0..43e3725 100644 --- a/tools/plugins/request-for-publish/README.md +++ b/tools/plugins/request-for-publish/README.md @@ -1,4 +1,4 @@ -# Request for Publish Plugin +# Request for Plugin A DA (Document Authoring) plugin that enables content authors to submit publish requests for approval. This plugin is the **author-facing** side of the publish workflow, appearing as a dialog within the DA editing environment. @@ -23,7 +23,7 @@ When an author finishes editing content and wants to publish it, they open this 1. The plugin loads and fetches the current user's email from Adobe IMS 2. The content path is derived from the DA SDK context (`/{org}/{site}{path}` → strips org/site prefix) 3. It reads the workflow config via the DA Config API (`/config/{org}/{site}/`, falling back to `/config/{org}/`) — if the `publish-workflow-config` tab is not found at either level, an error message is displayed and the form is not shown -4. Distribution list (DL) groups in the Approvers and CC fields are resolved to individual emails using the `publish-workflow-groups-to-email` tab +4. Distribution list (DL) groups in the Approvers and CC fields are resolved to individual emails using the `groups-to-email` tab 5. It checks the requests sheet (`/.da/publish-workflow-requests.json`) for any existing pending request by this user for this path 6. If a pending request exists, it shows a "Request Pending" state instead of the form 7. Otherwise, the submission form is rendered with pre-filled details @@ -38,7 +38,7 @@ DA Config API → /config/{org}/{site}/ → publish-workflow-config tab Each rule has a **Pattern** (e.g., `/drafts/*`, `/*`), **Approvers** (comma-separated emails or DL names), and an optional **CC** column (comma-separated emails or DL names). If no rule matches the path, an error is shown. -When an approver or CC entry matches a group in the `publish-workflow-groups-to-email` tab, it is expanded to individual email addresses. CC recipients that overlap with approvers are automatically deduplicated by the worker to avoid sending duplicate emails. +When an approver or CC entry matches a group in the `groups-to-email` tab, it is expanded to individual email addresses. CC recipients that overlap with approvers are automatically deduplicated by the worker to avoid sending duplicate emails. #### Pattern Matching (Specificity-Based) @@ -56,7 +56,7 @@ When multiple rules could match a given content path, the plugin selects the **m | `/drafts/*` | `user2@example.com` | `dl-leads@example.com` | | `/*` | `dl-reviewers@example.com` | `ops-team@example.com` | -**publish-workflow-groups-to-email tab:** +**groups-to-email tab:** | group | email | |-------|-------| | `dl-reviewers@example.com` | `reviewer1@example.com` | @@ -74,18 +74,15 @@ If no matching rule is found, an error message is shown prompting the user to ad 2. Author optionally reviews the content diff via the AEM Page Status diff tool link 3. Author adds a note for the reviewers (optional by default; can be made mandatory with a minimum length via `publish-workflow-settings`) 4. Author clicks **"Request Publish"** -5. The plugin first refreshes the preview of your content so that when approvers receive the notification and open the preview link, they see your latest changes. The button will briefly show the request state before the confirmation appears -6. The plugin sends the request to the Cloudflare Worker (`/api/request-publish`), which triggers email notifications to all resolved approvers with CC recipients copied -7. On success, it writes a new entry to `/.da/publish-workflow-requests.json` with status `pending`, including the author's comment -8. A success confirmation is shown listing the notified approvers and CC'd recipients - +5. The plugin sends the request to the Cloudflare Worker (`/api/request-publish`), which triggers email notifications to all resolved approvers with CC recipients copied +6. On success, it writes a new entry to `/.da/publish-workflow-requests.json` with status `pending`, including the author's comment +7. A success confirmation is shown listing the notified approvers and CC'd recipients ## Use Cases Handled ### 1. Submit a New Publish Request The primary use case. The author sees the content path, preview URL, resolved approvers and CC recipients (with DLs expanded), and a content diff link. They add a description/note (optional by default; mandatory when `request.comments.required` is `true` in `publish-workflow-settings`) and submit. This: -- Previews the content via the AEM Admin API so the `.aem.page` preview is refreshed before approvers receive the notification - Sends the request via the Cloudflare Worker which emails the approvers (with CC recipients copied) with a review link - Records the pending request in the DA requests sheet (requester, approver, path, comment, status) - Shows a success confirmation with the list of notified approvers and CC'd recipients @@ -166,7 +163,6 @@ The `publish-workflow-settings` tab holds key-value pairs that control optional |-----|-------|-------------| | `request.comments.required` | `true` or `false` | When `true`, the description field ("Please provide a description of your website content changes...") becomes mandatory. Default: `false`. | | `request.comments.length` | number | Minimum character length for the description when comments are required. Fallback: `10` if missing or invalid. | -| `approvals.cc.can-approve` | `true` or `false` | When `true`, CC recipients are authorized to view and approve requests (same rights as Approvers). Default: `false` — only Approvers can approve. | **Example:** @@ -174,7 +170,6 @@ The `publish-workflow-settings` tab holds key-value pairs that control optional |-----|-------| | `request.comments.required` | `true` | | `request.comments.length` | `25` | -| `approvals.cc.can-approve` | `true` | ### `/.da/publish-workflow-requests.json` (DA Source API) diff --git a/tools/plugins/request-for-publish/request-for-publish.css b/tools/plugins/request-for-publish/request-for-publish.css index dfd812c..14e196a 100644 --- a/tools/plugins/request-for-publish/request-for-publish.css +++ b/tools/plugins/request-for-publish/request-for-publish.css @@ -1,343 +1,295 @@ -/* =========================== - Request for Publish Plugin - Design tokens aligned with DA.live (nexter.css) - See: https://github.com/adobe/da-live - Context: sidebar / panel plugin - =========================== */ - +/* + * Spectrum 2 scale tokens (--s2-*) + Super Lite sl-* controls. + * Aligned with publish-requests-inbox app styles and Adobe Spectrum / S2 desktop reference. + */ :host { - /* DA.live design tokens (from nexter.css) */ - --body-font-family: "Adobe Clean", adobe-clean, "Trebuchet MS", sans-serif; - --fixed-font-family: "Roboto Mono", menlo, consolas, "Liberation Mono", monospace; - - /* Colors — Spectrum 2 palette */ - --s2-blue-200: #e5f0fe; - --s2-blue-700: #5989ff; - --s2-blue-800: #3b63fb; - --s2-blue-900: #3b63fb; - --s2-blue-1000: #274dea; - --s2-blue-1200: #1532ad; - --s2-green: #009851; - --s2-red: #f73b26; - --s2-red-200: #fde8e6; - --s2-red-500: #903; - --s2-red-600: #730026; - --s2-red-1200: #670800; - --s2-gray-50: #f7f7f7; - --s2-gray-75: #f1f1f1; - --s2-gray-200: #e1e1e1; - --s2-gray-700: #505050; - --s2-gray-800: #292929; - --s2-gray-900: #121212; - - /* Spacing */ - --spacing-50: 2px; - --spacing-75: 4px; - --spacing-100: 8px; - --spacing-200: 12px; - --spacing-300: 16px; - --spacing-400: 24px; - --spacing-500: 32px; - --spacing-600: 40px; - --spacing-700: 48px; - --spacing-800: 64px; - - /* Corner radii */ - --s2-radius-75: 4px; - --s2-radius-100: 8px; - --s2-radius-200: 10px; - --s2-radius-300: 16px; - - /* Font sizing */ - --s2-font-size-100: 14px; - --s2-font-size-200: 16px; - --s2-font-size-300: 18px; - --s2-font-size-400: 20px; - - /* Heading sizing */ - --s2-heading-size-300: 18px; - --s2-heading-size-400: 20px; - --s2-heading-size-500: 22px; - - /* Semantic aliases */ - --color-primary: var(--s2-red-500); - --color-primary-hover: var(--s2-red-600); - --color-success: var(--s2-green); - --color-danger: var(--s2-red); - --color-text: var(--s2-gray-800); - --color-text-muted: var(--s2-gray-700); - --color-bg-light: var(--s2-gray-50); - --color-bg-code: var(--s2-gray-75); - --color-border: var(--s2-gray-200); - --color-link: var(--s2-blue-800); - --color-link-hover: var(--s2-blue-1000); + --pw-accent: var(--s2-blue-900); + --pw-accent-hover: var(--s2-blue-1000); display: block; height: 100%; overflow: auto; -webkit-font-smoothing: antialiased; - font-family: var(--body-font-family, "Adobe Clean", adobe-clean, "Trebuchet MS", sans-serif); - color: var(--s2-gray-800, #292929); + font-family: var(--body-font-family); + color: var(--s2-gray-800); +} + +/* Host classes avoid nexter .accent / .negative (see publish-requests-inbox.css). */ +sl-button.pw-fill-accent, +sl-button.pw-fill-negative { + background: transparent; + border: none; + padding: 0; + vertical-align: middle; +} + +sl-button.pw-fill-accent::part(base) { + background: var(--pw-accent); + border-color: var(--pw-accent); + color: var(--s2-gray-75); +} + +sl-button.pw-fill-accent::part(base):hover:not(:disabled) { + background: var(--pw-accent-hover); + border-color: var(--pw-accent-hover); +} + +sl-button.pw-fill-accent::part(base):disabled { + opacity: 0.4; + cursor: not-allowed; +} + +sl-button.pw-fill-negative::part(base) { + background: var(--s2-red-900); + border-color: var(--s2-red-900); + color: var(--s2-gray-75); +} + +sl-button.pw-fill-negative::part(base):hover:not(:disabled) { + filter: brightness(0.92); +} + +sl-button.pw-fill-negative::part(base):disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.form-group sl-textarea { + display: block; + width: 100%; } /* ========== Loading ========== */ +.spectrum-loading-indicator { + width: 32px; + height: 32px; + border-radius: 50%; + border: 3px solid var(--s2-gray-200); + border-top-color: var(--pw-accent); + animation: pw-spectrum-spin 0.85s linear infinite; +} + +.loading-label { + margin: 0; + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); +} + +@keyframes pw-spectrum-spin { + to { + transform: rotate(360deg); + } +} + .loading-container { display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: var(--spacing-200); height: 100%; min-height: 200px; - color: var(--color-text-muted); -} - -.loading-spinner { - width: 40px; - height: 40px; - border: 3px solid var(--s2-gray-75); - border-top: 3px solid var(--color-primary); - border-radius: 50%; - animation: spin 1s linear infinite; - margin-bottom: var(--spacing-300); -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } + color: var(--s2-gray-700); } /* ========== Messages ========== */ .message { padding: var(--spacing-200) var(--spacing-300); + margin-bottom: var(--spacing-200); border-radius: var(--s2-radius-100); - margin-bottom: var(--spacing-300); - font-size: var(--s2-font-size-100); + font-size: var(--s2-body-s-size); line-height: 1.5; } .message.success { - background: #e6f4ea; - border: 1px solid #b7dfbf; - color: #1a6b2a; + background: var(--s2-green-100); + color: var(--s2-green-900); } .message.error { - background: #fce8e6; - border: 1px solid #f5c6c2; - color: #b3261e; + background: var(--s2-red-100); + color: var(--s2-red-900); } .message.info { - background: var(--s2-red-200); - border: 1px solid #b8d4fe; - color: var(--s2-red-1200); + background: var(--s2-gray-100); + color: var(--s2-gray-800); } .message.warning { - background: #fff8e1; - border: 1px solid #ffe082; - color: #7a5500; + background: var(--s2-orange-100); + color: var(--s2-orange-800); } -/* ========== Result / Status States ========== */ -.result-container { +/* ========== Form Container ========== */ +.form-container { padding: var(--spacing-400); - text-align: center; } -.result-icon { - font-size: 48px; - margin-bottom: var(--spacing-300); -} - -.result-container h3 { - margin: 0 0 var(--spacing-200) 0; - font-size: var(--s2-heading-size-400); +.form-header h3 { + margin: 0 0 var(--spacing-75) 0; + font-size: var(--s2-heading-xs-size); font-weight: 700; + color: var(--s2-gray-900); } -.result-container.success h3 { - color: #1a6b2a; +.form-subtitle { + color: var(--s2-gray-600); + font-size: var(--s2-body-s-size); + margin: 0 0 var(--spacing-400) 0; + line-height: 1.5; } -.result-container.pending h3 { - color: var(--color-text); +/* ========== Review Card (shared card treatment — matches inbox app) ========== */ +.review-card { + background: var(--s2-gray-50); + border: 1px solid var(--s2-gray-200); + border-radius: var(--s2-radius-200); + padding: var(--spacing-300) var(--spacing-400); + margin-bottom: var(--spacing-300); } -.result-container p { +.review-card-title { margin: 0 0 var(--spacing-200) 0; - color: var(--color-text); + font-size: var(--s2-body-m-size); + font-weight: 700; + color: var(--s2-gray-900); } -.result-note { - margin-top: var(--spacing-300) !important; - font-size: 13px; - color: var(--color-text-muted); +.review-card-title.cc-title { + margin-top: var(--spacing-200); + padding-top: var(--spacing-200); + border-top: 1px solid var(--s2-gray-200); } -.my-pending-requests-link { - margin-top: var(--spacing-300) !important; - font-size: 13px; - color: var(--color-text-muted); +.review-card-body { + margin: 0 0 var(--spacing-200) 0; + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); + line-height: 1.5; } -/* ========== Info Card ========== */ -.info-card { - background: var(--color-bg-light); - border-radius: var(--s2-radius-100); - padding: var(--spacing-300); - margin-bottom: var(--spacing-300); +/* ========== Detail List (dl/dt/dd inside cards) ========== */ +.detail-list { + margin: 0; + padding: 0; + display: flex; + flex-direction: column; } -.info-row { +.detail-row { display: flex; - align-items: center; - text-align: left; - gap: var(--spacing-100); - margin-bottom: var(--spacing-100); - font-size: 13px; + align-items: baseline; + gap: var(--spacing-200); + padding: var(--spacing-150) 0; + border-bottom: 1px solid var(--s2-gray-200); } -.info-row:last-child { - margin-bottom: 0; +.detail-row:last-child { + border-bottom: none; + padding-bottom: 0; } -.info-row .label { - color: var(--color-text-muted); - min-width: 120px; - flex-shrink: 0; +.detail-row:first-child { + padding-top: 0; } -.info-row .value, -.info-row code { - background: var(--color-bg-code); - padding: var(--spacing-75) var(--spacing-100); - border-radius: var(--s2-radius-75); - font-family: var(--fixed-font-family); - font-size: 12px; - word-break: break-all; -} - -.info-row a { - color: var(--color-link); - text-decoration: none; - font-weight: 700; +.detail-row dt { + flex-shrink: 0; + width: 100px; + font-size: var(--s2-body-s-size); + font-weight: 600; + color: var(--s2-gray-600); } -.info-row a:hover, -.my-pending-requests-link a:hover { - color: var(--color-link-hover); - text-decoration: underline; +.detail-row dd { + margin: 0; + font-size: var(--s2-body-s-size); + color: var(--s2-gray-900); + word-break: break-all; } -/* ========== Diff Section ========== */ -.diff-section { - margin-bottom: var(--spacing-300); +.detail-row dd code { + font-family: var(--fixed-font-family); + font-size: var(--s2-body-s-size); + background: var(--s2-gray-100); + padding: var(--spacing-75) var(--spacing-200); + border-radius: var(--s2-radius-75); } -.diff-header { - display: flex; +/* ========== Action Links ========== */ +.action-link { + display: inline-flex; align-items: center; - gap: var(--spacing-100); - margin-bottom: var(--spacing-100); + gap: var(--spacing-50); + padding: var(--spacing-75) var(--spacing-200); + font-size: var(--s2-body-s-size); + font-weight: 600; + color: var(--pw-accent); + text-decoration: none; + border-radius: var(--s2-radius-100); + transition: background 0.15s ease, color 0.15s ease; } -.diff-icon { - font-size: 18px; +.action-link:hover { + background: var(--s2-gray-100); + color: var(--pw-accent-hover); } -.diff-title { - font-weight: 700; - font-size: var(--s2-font-size-100); - color: var(--color-text); +.action-link:focus-visible { + outline: 2px solid var(--pw-accent); + outline-offset: 2px; } -.diff-description { - margin: 0 0 var(--spacing-200) 0; - font-size: 13px; - color: var(--color-text-muted); +.action-icon { + width: 14px; + height: 14px; + fill: currentColor; + flex-shrink: 0; } -/* fix 3: general selector before the scoped .diff-header override */ -.open-diff-link { - display: inline-flex; - align-items: center; +/* ========== SMART Checklist ========== */ +.smart-checklist { + list-style: none; + margin: 0 0 var(--spacing-300) 0; + padding: 0; + display: flex; + flex-direction: column; gap: var(--spacing-75); - margin-top: var(--spacing-100); - font-size: 13px; - color: var(--color-link); - text-decoration: none; - font-weight: 700; -} - -.open-diff-link:hover { - color: var(--color-link-hover); - text-decoration: underline; -} - -.diff-header .open-diff-link { - margin-left: auto; - margin-top: 0; } -/* ========== Approvers Section ========== */ -.approvers-section { - background: var(--s2-red-200); - border: 1px solid #b8d4fe; - border-left: 4px solid var(--color-primary); - border-radius: var(--s2-radius-100); - padding: var(--spacing-300); - margin-bottom: var(--spacing-400); -} - -.approvers-header { +.smart-checklist li { + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); display: flex; align-items: center; gap: var(--spacing-100); - margin-bottom: var(--spacing-200); -} - -.approvers-icon { - font-size: 18px; } -.approvers-title { +.smart-checklist li strong { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: var(--s2-radius-75); + background: var(--s2-gray-200); + color: var(--s2-gray-800); + font-size: 11px; font-weight: 700; - font-size: var(--s2-font-size-100); - color: var(--color-text); + flex-shrink: 0; } +/* ========== Approvers List ========== */ .approvers-list { margin: 0; - padding-left: var(--spacing-400); - font-size: var(--s2-font-size-100); - color: var(--color-text); - list-style-type: none; -} - -.approvers-list li { - margin-bottom: var(--spacing-75); -} - -.approvers-list li:last-child { - margin-bottom: 0; -} - -.approvers-note { - margin: var(--spacing-200) 0 0 0; - font-size: 12px; - color: var(--color-text-muted); -} - -/* ========== Form Container ========== */ -.form-container { - padding: var(--spacing-400); -} - -.form-subtitle { - color: var(--color-text-muted); - font-size: var(--s2-font-size-100); - margin: 0 0 var(--spacing-400) 0; + padding: 0; + list-style: none; + font-size: var(--s2-body-s-size); + color: var(--s2-gray-800); + display: flex; + flex-direction: column; + gap: var(--spacing-75); } /* ========== Form Elements ========== */ @@ -347,125 +299,109 @@ .form-group label { display: block; - font-size: var(--s2-font-size-100); - font-weight: 700; - margin-bottom: 6px; - color: var(--color-text); + font-size: var(--s2-body-s-size); + font-weight: 600; + margin-bottom: var(--spacing-75); + color: var(--s2-gray-800); } .form-group label .required, .form-group label .required-marker { - color: var(--color-danger); + color: var(--s2-red-900); } .form-group .field-hint { display: block; margin-top: var(--spacing-75); - font-size: 12px; - color: var(--color-text-muted); -} - -.form-group input[type="email"], -.form-group textarea { - width: 100%; - padding: 10px var(--spacing-200); - border: 1px solid var(--color-border); - border-radius: var(--s2-radius-100); - font-size: var(--s2-font-size-100); - font-family: var(--body-font-family); - box-sizing: border-box; - color: var(--color-text); -} - -.form-group input[type="email"]:focus, -.form-group textarea:focus { - outline: none; - border-color: var(--color-primary); - box-shadow: 0 0 0 3px rgb(59 99 251 / 15%); -} - -.form-group .email-display { - margin: 0; - padding: 10px var(--spacing-200); - background: var(--color-bg-light); - border-radius: var(--s2-radius-100); - font-size: var(--s2-font-size-100); - color: var(--color-text); + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); } .form-actions { + display: flex; + justify-content: flex-start; margin-top: var(--spacing-400); } -/* ========== Buttons ========== */ -button { - cursor: pointer; - font-family: var(--body-font-family); - transition: all 0.2s ease; +/* ========== Status Pages (submitted, withdrawn, existing request) ========== */ +.status-page { + text-align: center; + padding: var(--spacing-400); } -.btn-primary { - background: var(--color-primary); - color: white; - border: none; - padding: var(--spacing-200) var(--spacing-400); - border-radius: var(--s2-radius-300); - font-weight: 700; - font-size: 15px; - line-height: 16px; - display: inline-block; - text-decoration: none; +.status-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 50%; + margin-bottom: var(--spacing-300); } -.btn-primary:disabled { - opacity: 0.5; - cursor: not-allowed; +.status-icon svg { + width: 24px; + height: 24px; } -.btn-primary:hover:not(:disabled) { - background: var(--color-primary-hover); +.status-icon--success { + background: var(--s2-green-100); + color: var(--s2-green-900); } -.btn-large { - width: 100%; - padding: 14px var(--spacing-400); - font-size: var(--s2-font-size-200); +.status-icon--error { + background: var(--s2-red-100); + color: var(--s2-red-900); +} + +.status-icon--neutral { + background: var(--s2-gray-200); + color: var(--s2-gray-700); } -.btn-secondary { - background: white; - color: var(--color-text); - border: 1px solid var(--color-border); - padding: var(--spacing-200) var(--spacing-400); - border-radius: var(--s2-radius-300); +.status-heading { + margin: 0 0 var(--spacing-100) 0; + font-size: var(--s2-heading-xs-size); font-weight: 700; - font-size: 15px; - line-height: 16px; - display: inline-block; - text-decoration: none; + color: var(--s2-gray-900); } -.btn-secondary:hover:not(:disabled) { - background: var(--color-bg-light); - border-color: var(--s2-gray-700); +.status-heading--success { + color: var(--s2-green-900); } -.btn-withdraw { - color: var(--color-danger); - border-color: var(--color-danger); +.status-body { + margin: 0 0 var(--spacing-400) 0; + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); + line-height: 1.5; } -.btn-withdraw:hover:not(:disabled) { - background: #fce8e6; - border-color: var(--color-danger); +.status-page .review-card { + text-align: left; } -/* Existing request action buttons layout */ -.result-container .form-actions { +.status-actions { display: flex; - flex-direction: row; justify-content: center; gap: var(--spacing-200); - margin-top: var(--spacing-300); - margin-bottom: var(--spacing-200); + margin-bottom: var(--spacing-300); +} + +.status-note { + margin: var(--spacing-200) 0 0; + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); + line-height: 1.5; +} + +.status-note a:not(.action-link) { + color: var(--pw-accent); + text-decoration: none; + font-weight: 600; +} + +.status-note a:not(.action-link):hover { + color: var(--pw-accent-hover); + text-decoration: underline; } diff --git a/tools/plugins/request-for-publish/request-for-publish.html b/tools/plugins/request-for-publish/request-for-publish.html index c006f48..55d3414 100644 --- a/tools/plugins/request-for-publish/request-for-publish.html +++ b/tools/plugins/request-for-publish/request-for-publish.html @@ -7,6 +7,7 @@ + @@ -15,6 +16,6 @@ - + diff --git a/tools/plugins/request-for-publish/request-for-publish.js b/tools/plugins/request-for-publish/request-for-publish.js index 31320a5..8a6568a 100644 --- a/tools/plugins/request-for-publish/request-for-publish.js +++ b/tools/plugins/request-for-publish/request-for-publish.js @@ -12,8 +12,32 @@ import { checkExistingRequest, } from './utils.js'; -// Super Lite components -import 'https://da.live/nx/public/sl/components.js'; +// Super Lite (sl-*) — Spectrum-aligned controls for DA; pairs with S2 tokens in CSS. +// NX style pipeline matches other da.live shell apps: nexter.js loadStyle + getStyle. +const NX = 'https://da.live/nx'; +let nexter = null; +let sl = null; +let styles = null; +let buttons = null; +try { + const [{ default: getStyle }, { loadStyle }] = await Promise.all([ + import(`${NX}/utils/styles.js`), + import(`${NX}/scripts/nexter.js`), + ]); + await Promise.all([ + loadStyle(`${NX}/styles/nexter.css`), + loadStyle(`${NX}/public/sl/styles.css`), + ]); + await import(`${NX}/public/sl/components.js`); + [nexter, sl, styles, buttons] = await Promise.all([ + getStyle(`${NX}/styles/nexter.css`), + getStyle(`${NX}/public/sl/styles.css`), + getStyle(import.meta.url), + getStyle(`${NX}/styles/buttons.css`), + ]); +} catch (e) { + console.warn('Failed to load styles:', e); +} // RUM helper – safely fires a checkpoint if the RUM script is loaded function sampleRUM(checkpoint, data = {}) { @@ -22,15 +46,6 @@ function sampleRUM(checkpoint, data = {}) { } catch { /* noop */ } } -// Application styles - load with error handling -let styles = null; -try { - const loadStyle = (await import('../../scripts/utils/styles.js')).default; - styles = await loadStyle(import.meta.url); -} catch (e) { - console.warn('Failed to load styles:', e); -} - class RequestForPublishPlugin extends LitElement { static properties = { context: { attribute: false }, @@ -47,6 +62,7 @@ class RequestForPublishPlugin extends LitElement { _existingRequest: { state: true }, _isResending: { state: true }, _isWithdrawing: { state: true }, + _withdrawn: { state: true }, _commentsRequired: { state: true }, _commentsMinLength: { state: true }, _submitPhase: { state: true }, @@ -65,6 +81,7 @@ class RequestForPublishPlugin extends LitElement { this._existingRequest = null; this._isResending = false; this._isWithdrawing = false; + this._withdrawn = false; this._commentsRequired = false; this._commentsMinLength = 10; this._submitPhase = ''; @@ -72,9 +89,7 @@ class RequestForPublishPlugin extends LitElement { connectedCallback() { super.connectedCallback(); - if (styles) { - this.shadowRoot.adoptedStyleSheets = [styles]; - } + this.shadowRoot.adoptedStyleSheets = [nexter, sl, buttons, styles].filter(Boolean); this.init(); } @@ -123,6 +138,13 @@ class RequestForPublishPlugin extends LitElement { this._commentsRequired = result.commentsRequired || false; this._commentsMinLength = result.commentsMinLength ?? 10; + if (result.accentColor) { + this.style.setProperty('--pw-accent', result.accentColor); + } + if (result.accentColorHover) { + this.style.setProperty('--pw-accent-hover', result.accentColorHover); + } + // Show error if config is missing or no matching rule found if (result.error) { this._message = { type: 'error', text: result.error }; @@ -144,24 +166,20 @@ class RequestForPublishPlugin extends LitElement { this._isLoading = false; } - async handleSubmit(e) { - e.preventDefault(); + async handleSubmit() { if (this._isSubmitting) return; this._isSubmitting = true; this._message = null; - const form = this.shadowRoot.querySelector('form'); - const formData = new FormData(form); - const comment = formData.get('comment')?.trim() || ''; + const textarea = this.shadowRoot.querySelector('#comment'); + const comment = (textarea?.value ?? '').trim(); - // Validate comment if required if (this._commentsRequired && comment.length < this._commentsMinLength) { this._isSubmitting = false; this._message = { type: 'error', text: `Please provide a description of at least ${this._commentsMinLength} characters.` }; return; } - // Use auto-fetched email from IMS profile const authorEmail = this._userEmail; if (!authorEmail) { this._isSubmitting = false; @@ -249,31 +267,17 @@ class RequestForPublishPlugin extends LitElement { if (result.success) { this._existingRequest = null; - this._message = { type: 'success', text: 'Publish request withdrawn successfully.' }; + this._withdrawn = true; } else { this._message = { type: 'error', text: result.error || 'Failed to withdraw request.' }; } } - _handleCommentInvalid(e) { - const textarea = e.target; - if (!this._commentsRequired) return; - if (textarea.validity.valueMissing || textarea.validity.tooShort) { - textarea.setCustomValidity(`Please provide a description of at least ${this._commentsMinLength} characters.`); - } else { - textarea.setCustomValidity(''); - } - } - - _handleCommentInput(e) { - e.target.setCustomValidity(''); - } - renderLoading() { return html` -
-
-

Loading...

+
+ +

Loading…

`; } @@ -286,68 +290,95 @@ class RequestForPublishPlugin extends LitElement { renderExistingRequest() { const actionDisabled = this._isResending || this._isWithdrawing; return html` -
-
-

Request Pending

-

You already have a pending publish request for this content. Please wait while your request is reviewed.

-
-
- Content Path: - ${this._existingRequest.path} -
-
- Approver: - ${this._existingRequest.approver} -
-
- Status: - ${this._existingRequest.status} -
+
+
+
+

Request Pending

+

You already have a pending publish request for this content. Please wait while your request is reviewed.

+ +
+
+
+
Content
+
${this._existingRequest.path}
+
+
+
Approver
+
${this._existingRequest.approver}
+
+
+
Status
+
${this._existingRequest.status}
+
+
+
${this.renderMessage()} -
- - +
-

If your content owner is away please contact digiops@westernsydney.edu.au for assistance with content approvals.

+

If your content owner is away please contact digiops@westernsydney.edu.au for assistance with content approvals.

+
+ `; + } + + renderWithdrawn() { + return html` +
+
+ +
+

Request Withdrawn

+

Your publish request for ${this.contentPath} has been withdrawn successfully.

+

You can submit a new publish request at any time.

`; } renderSubmitted() { return html` -
-
-

Request Sent!

-

Your publish request has been sent to the following approvers:

-
    - ${this._approvers.map((approver) => html`
  • ${approver}
  • `)} -
- ${this._cc.length > 0 ? html` -

CC'd:

+
+
+ +
+

Request Sent!

+

Your publish request has been sent to the following approvers:

+ +
    - ${this._cc.map((email) => html`
  • ${email}
  • `)} + ${this._approvers.map((approver) => html`
  • ${approver}
  • `)}
- ` : nothing} + ${this._cc.length > 0 ? html` +

CC'd

+
    + ${this._cc.map((email) => html`
  • ${email}
  • `)} +
+ ` : nothing} +
+ ${this.renderMessage()} -

You will receive an email when your request is approved or rejected.

-

You will receive an email when your request is approved or rejected.

+

+ + + View all my pending publish requests +

`; @@ -356,93 +387,84 @@ class RequestForPublishPlugin extends LitElement { renderForm() { return html`
-

Request Publish

-

Submit this website update for approval

- -
-
- Page URL: - ${this.contentPath} -
-
- Requested By: - ${this._userEmail} -
-
- Preview Page URL: - - View Preview ↗ - -
-
- -
-
- Content Changes -
-

- Before submitting for approval please carefully proofread and review your edits. Have you been SMART?
- S – Streamline Site Structure
- M – Metadata for SEO
- A – Accessibility compliant
- R – Redirects requested
- T – Tested all links
- View Existing Page↗ -

- -
- -
-
- - Will be reviewed by: -
+
+

Request Publish

+

Submit this website update for approval

+
+ +
+

Request Details

+
+
+
Page
+
${this.contentPath}
+
+
+
Requested by
+
${this._userEmail}
+
+
+
Preview
+
+ + + View Preview + +
+
+
+
+ +
+

Content Changes

+

Before submitting, please proofread and review your edits. Have you been SMART?

+
    +
  • S Streamline Site Structure
  • +
  • M Metadata for SEO
  • +
  • A Accessibility compliant
  • +
  • R Redirects requested
  • +
  • T Tested all links
  • +
+ + + View Existing Page + +
+ +
+

Will be reviewed by

    - ${this._approvers.map((approver) => html`
  • ${approver}
  • `)} + ${this._approvers.map((approver) => html`
  • ${approver}
  • `)}
${this._cc.length > 0 ? html` -
- - CC: -
+

CC

    - ${this._cc.map((email) => html`
  • ${email}
  • `)} + ${this._cc.map((email) => html`
  • ${email}
  • `)}
` : nothing} +
+ +
+ + + ${this._commentsRequired ? html`Minimum ${this._commentsMinLength} characters required.` : nothing}
${this.renderMessage()} - -
-
- - - ${this._commentsRequired ? html`Minimum ${this._commentsMinLength} characters required.` : nothing} -
- -
- -
-
+
+ this.handleSubmit()} + ?disabled=${this._isSubmitting} + > + ${this._submitButtonLabel} + +
`; } @@ -452,6 +474,10 @@ class RequestForPublishPlugin extends LitElement { return this.renderLoading(); } + if (this._withdrawn) { + return this.renderWithdrawn(); + } + if (this._submitted) { return this.renderSubmitted(); } diff --git a/tools/plugins/request-for-publish/utils.js b/tools/plugins/request-for-publish/utils.js index 79498ef..b5c5c3a 100644 --- a/tools/plugins/request-for-publish/utils.js +++ b/tools/plugins/request-for-publish/utils.js @@ -275,6 +275,10 @@ export async function resolveWorkflowConfig(path, org, site, token) { const commentsLengthSetting = extractSetting(config, 'request.comments.length'); const commentsMinLength = parseInt(commentsLengthSetting, 10) || 10; + // Per-customer accent color overrides + const accentColor = extractSetting(config, 'theme.accent-color'); + const accentColorHover = extractSetting(config, 'theme.accent-color-hover'); + // Multi-sheet format: tabs are 'publish-workflow-config' and 'publish-workflow-groups-to-email' const rules = config['publish-workflow-config']?.data || config.data || config.rules || []; const groupsData = config['publish-workflow-groups-to-email']?.data || []; @@ -309,6 +313,8 @@ export async function resolveWorkflowConfig(path, org, site, token) { source: 'config', commentsRequired, commentsMinLength, + accentColor, + accentColorHover, digiops: rule.DigiOps || rule.digiops || config.digiops || '', }; } @@ -321,6 +327,8 @@ export async function resolveWorkflowConfig(path, org, site, token) { source: 'no-match', commentsRequired, commentsMinLength, + accentColor, + accentColorHover, error: `No approver rule found matching path "${path}". Please add a matching pattern to the "publish-workflow-config" tab.`, }; } diff --git a/tools/scripts/utils/styles.js b/tools/scripts/utils/styles.js index 7747f74..90b0bff 100644 --- a/tools/scripts/utils/styles.js +++ b/tools/scripts/utils/styles.js @@ -1,3 +1,5 @@ +const NX_BASE = 'https://da.live/nx'; + const loadStyle = (() => { const styles = {}; @@ -20,4 +22,8 @@ const loadStyle = (() => { }; })(); +export function loadButtons() { + return loadStyle(`${NX_BASE}/styles/buttons.css`); +} + export default loadStyle; From c345115b5abb3a71c340e5b56fc4743466ab1cc6 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Mon, 20 Apr 2026 15:24:10 -0400 Subject: [PATCH 02/20] self serve if the site is not registered for Publish request --- tools/apps/publish-requests-inbox/api.js | 67 +++++ .../publish-requests-inbox.css | 82 ++++++ .../publish-requests-inbox.html | 1 + .../publish-requests-inbox.js | 239 +++++++++++++++++- .../request-for-publish.html | 1 + .../request-for-publish.js | 2 +- 6 files changed, 388 insertions(+), 4 deletions(-) diff --git a/tools/apps/publish-requests-inbox/api.js b/tools/apps/publish-requests-inbox/api.js index 6cb8f5b..e127ddc 100644 --- a/tools/apps/publish-requests-inbox/api.js +++ b/tools/apps/publish-requests-inbox/api.js @@ -834,6 +834,73 @@ export async function getAllPendingRequestsByRequester(org, site, userEmail, tok ); } +/** + * Check whether a DA site (repo) exists by listing the org's children. + * GET /list/{org} returns all repos; we check if `site` is among them. + * @param {string} org - Organization + * @param {string} site - Site (repo name) + * @param {string} token - Authorization token + * @returns {Promise} true if the site exists, false otherwise + */ +export async function checkSiteExists(org, site, token) { + try { + const url = `${DA_ADMIN}/list/${org}`; + const resp = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!resp.ok) return false; + const items = await resp.json(); + return items.some((item) => item.name === site); + } catch { + return false; + } +} + +/** + * Check whether org--site is registered in the worker's KV store. + * GET /api/site-config?org={org}&site={site} + * @param {string} org - Organization + * @param {string} site - Site + * @param {string} token - Authorization token + * @returns {Promise<{registered: boolean, config: Object|null}>} + */ +export async function checkSiteRegistration(org, site, token) { + try { + const url = `${getWorkerUrl()}/api/site-config?org=${encodeURIComponent(org)}&site=${encodeURIComponent(site)}`; + const resp = await fetch(url, getOpts(token, 'GET')); + if (resp.status === 404) return { registered: false, config: null }; + if (!resp.ok) return { registered: false, config: null }; + const config = await resp.json(); + return { registered: true, config }; + } catch { + return { registered: false, config: null }; + } +} + +/** + * Register a new org--site in the worker's KV store with email provider config. + * POST /api/site-config + * @param {string} org - Organization + * @param {string} site - Site + * @param {Object} emailConfig - Email provider configuration + * @param {string} token - Authorization token + * @returns {Promise} Result + */ +export async function registerSite(org, site, emailConfig, token) { + try { + const opts = getOpts(token, 'POST', { org, site, ...emailConfig }); + const resp = await fetch(`${getWorkerUrl()}/api/site-config`, opts); + const result = await resp.json(); + if (!resp.ok) { + return { success: false, error: result.error || 'Registration failed' }; + } + return { success: true, data: result }; + } catch (error) { + console.error('Error registering site:', error); + return { success: false, error: error.message || 'An error occurred' }; + } +} + /** * Fetch the current user's email from Adobe IMS profile * @param {string} token - The authorization token diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.css b/tools/apps/publish-requests-inbox/publish-requests-inbox.css index d9f9816..93e759a 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.css +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.css @@ -748,3 +748,85 @@ sl-button.pw-quiet-secondary::part(base):hover:not(:disabled) { color: var(--s2-gray-700); font-size: var(--s2-body-s-size); } + +/* ========== Registration ========== */ +.register-container { + max-width: 560px; + margin: var(--spacing-600) auto 0; +} + +.register-banner { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin-bottom: var(--spacing-400); +} + +.register-heading { + margin: 0 0 var(--spacing-100) 0; + font-size: var(--s2-heading-s-size); + font-weight: 700; + color: var(--s2-gray-900); +} + +.register-body { + margin: 0 0 var(--spacing-400) 0; + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); + line-height: 1.5; +} + +.register-form-card { + text-align: left; +} + +.register-form { + display: flex; + flex-direction: column; + gap: var(--spacing-100); +} + +.register-form .form-group { + margin-bottom: var(--spacing-100); +} + +.reg-select { + display: block; + width: 100%; + padding: var(--spacing-100) var(--spacing-200); + font-family: var(--body-font-family); + font-size: var(--s2-body-s-size); + color: var(--s2-gray-900); + background: var(--s2-gray-25, #fff); + border: 1px solid var(--s2-gray-300); + border-radius: var(--s2-radius-100); + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23464646'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right var(--spacing-200) center; + cursor: pointer; +} + +.reg-select:hover { + border-color: var(--s2-gray-400); +} + +.reg-select:focus-visible { + outline: 2px solid var(--pw-accent); + outline-offset: 1px; +} + +.form-hint { + display: block; + margin-top: var(--spacing-50); + font-size: var(--s2-detail-s-size, 11px); + color: var(--s2-gray-600); + line-height: 1.4; +} + +.register-form-actions { + display: flex; + gap: var(--spacing-200); + margin-top: var(--spacing-200); +} diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.html b/tools/apps/publish-requests-inbox/publish-requests-inbox.html index 10117cc..33f4f7d 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.html +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.html @@ -1,6 +1,7 @@ + Publish Request Review diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index ed8e358..30bdb81 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -19,6 +19,9 @@ import { fetchSiteConfig, getLiveHostFromConfig, fetchAccentSettings, + checkSiteExists, + checkSiteRegistration, + registerSite, } from './api.js'; // Super Lite (sl-*) — Spectrum-aligned controls for DA; pairs with S2 tokens in CSS. @@ -71,7 +74,8 @@ class PublishRequestsApp extends LitElement { context: { attribute: false }, token: { attribute: false }, // view states: 'loading', 'idle', 'inbox', 'review', 'approved', - // 'rejected', 'error', 'unauthorized', 'no-request' + // 'rejected', 'error', 'unauthorized', 'no-request', + // 'site-not-found', 'unregistered' _state: { state: true }, _isProcessing: { state: true }, _message: { state: true }, @@ -97,6 +101,11 @@ class PublishRequestsApp extends LitElement { _orgSiteValue: { state: true }, // Inline reject error _rejectError: { state: true }, + // Registration + _siteRegistered: { state: true }, + _registrationChecked: { state: true }, + _registerProcessing: { state: true }, + _showRegisterForm: { state: true }, }; constructor() { @@ -120,6 +129,10 @@ class PublishRequestsApp extends LitElement { this._myRequestActions = new Map(); this._siteSelectLoading = false; this._orgSiteValue = ''; + this._siteRegistered = null; + this._registrationChecked = false; + this._registerProcessing = false; + this._showRegisterForm = false; } connectedCallback() { @@ -342,6 +355,7 @@ class PublishRequestsApp extends LitElement { async handleSiteSelect(e) { if (e) e.preventDefault(); this._message = null; + this._showRegisterForm = false; const input = this.shadowRoot.querySelector('#org-site'); const orgSite = (input?.value ?? '').trim(); @@ -374,6 +388,32 @@ class PublishRequestsApp extends LitElement { this._path = ''; this._pendingRequests = []; + // Check site existence and registration in parallel + const [siteExists, regStatus] = await Promise.all([ + checkSiteExists(org, site, this.token), + checkSiteRegistration(org, site, this.token), + ]); + this._registrationChecked = true; + this._siteRegistered = regStatus.registered; + + if (!siteExists) { + this._siteSelectLoading = false; + this._state = 'site-not-found'; + const urlParams = { org, site }; + if (this._requester) urlParams.requester = 'true'; + this.updateUrl(urlParams); + return; + } + + if (!regStatus.registered) { + this._siteSelectLoading = false; + this._state = 'unregistered'; + const urlParams = { org, site }; + if (this._requester) urlParams.requester = 'true'; + this.updateUrl(urlParams); + return; + } + await this.loadSiteSettings(org, site); const urlParams = { org, site }; @@ -727,13 +767,70 @@ class PublishRequestsApp extends LitElement { } } + // ======== Registration handlers ======== + + toggleRegisterForm() { + this._showRegisterForm = !this._showRegisterForm; + } + + async handleRegister() { + this._registerProcessing = true; + this._message = null; + + const getVal = (id) => (this.shadowRoot.querySelector(`#${id}`)?.value ?? '').trim(); + const emailProvider = getVal('reg-email-provider') || 'default'; + const apiUrl = getVal('reg-api-url'); + const apiKey = getVal('reg-api-key'); + const fromAddress = getVal('reg-from-address'); + const fromName = getVal('reg-from-name'); + const domainsRaw = getVal('reg-allowed-domains'); + const allowedEmailDomains = domainsRaw + ? domainsRaw.split(',').map((d) => d.trim()).filter(Boolean) + : []; + + const emailConfig = { emailProvider }; + if (emailProvider === 'custom-api') { + if (!apiUrl) { + this._registerProcessing = false; + this._message = { type: 'error', text: 'API URL is required for custom API provider.' }; + return; + } + emailConfig.apiUrl = apiUrl; + if (apiKey) emailConfig.apiKey = apiKey; + } + if (fromAddress) emailConfig.fromAddress = fromAddress; + if (fromName) emailConfig.fromName = fromName; + if (allowedEmailDomains.length > 0) emailConfig.allowedEmailDomains = allowedEmailDomains; + + const result = await registerSite(this._org, this._site, emailConfig, this.token); + this._registerProcessing = false; + + if (result.success) { + this._siteRegistered = true; + this._showRegisterForm = false; + this._message = { type: 'success', text: `Site ${this._org}/${this._site} registered successfully!` }; + + // Now proceed to load the inbox + this._siteSelectLoading = true; + await this.loadSiteSettings(this._org, this._site); + if (this._requester) { + await this.initMyRequests(); + } else { + await this.initInbox(); + } + this._siteSelectLoading = false; + } else { + this._message = { type: 'error', text: result.error }; + } + } + // ======== Render helpers ======== renderLoading() { return html`
-

Loading…

+

Loading...

`; } @@ -773,7 +870,7 @@ class PublishRequestsApp extends LitElement { @click=${() => this.handleSiteSelect()} ?disabled=${this._siteSelectLoading} > - ${this._siteSelectLoading ? 'Loading…' : primaryLabel} + ${this._siteSelectLoading ? 'Loading...' : primaryLabel} @@ -792,6 +889,10 @@ class PublishRequestsApp extends LitElement { return this.renderUnauthorized(); case 'no-request': return this.renderNoRequest(); + case 'site-not-found': + return this.renderSiteNotFound(); + case 'unregistered': + return this.renderUnregistered(); case 'inbox': return this.renderInbox(); case 'my-requests': @@ -807,6 +908,138 @@ class PublishRequestsApp extends LitElement { } } + // ======== Unregistered site — registration form ======== + + renderUnregistered() { + return html` +
+
+
+ +
+

Site Not Registered

+

+ ${this._org}/${this._site} is not yet registered for the publish workflow. + Register it to enable publish request approvals and email notifications. +

+ ${!this._showRegisterForm ? html` + this.toggleRegisterForm()} + >Register Site + ` : nothing} +
+ + ${this._showRegisterForm ? this.renderRegisterForm() : nothing} +
+ `; + } + + renderRegisterForm() { + return html` +
+

Registration Settings

+

Configure the email provider and notification settings for this site.

+ +
+
+ + +
+ + ${this.renderCustomApiFields()} + +
+ + +
+ +
+ + +
+ +
+ + + Comma-separated list of domains allowed to receive notifications. +
+ +
+ this.handleRegister()} + ?disabled=${this._registerProcessing} + > + ${this._registerProcessing ? 'Registering...' : 'Register'} + + this.toggleRegisterForm()} + ?disabled=${this._registerProcessing} + >Cancel +
+
+
+ `; + } + + renderCustomApiFields() { + const sel = this.shadowRoot?.querySelector('#reg-email-provider'); + if (!sel || sel.value !== 'custom-api') return nothing; + return html` +
+ + +
+
+ + +
+ `; + } + + // ======== Site not found render ======== + + renderSiteNotFound() { + return html` +
+
+ +
+

Site Not Available

+

+ The site ${this._org}/${this._site} could not be found. + Please check the organization and site names and try again. +

+
+ `; + } + // ======== Inbox renders ======== renderInbox() { diff --git a/tools/plugins/request-for-publish/request-for-publish.html b/tools/plugins/request-for-publish/request-for-publish.html index 55d3414..771c248 100644 --- a/tools/plugins/request-for-publish/request-for-publish.html +++ b/tools/plugins/request-for-publish/request-for-publish.html @@ -1,6 +1,7 @@ + Request Publish diff --git a/tools/plugins/request-for-publish/request-for-publish.js b/tools/plugins/request-for-publish/request-for-publish.js index 8a6568a..2a34a5d 100644 --- a/tools/plugins/request-for-publish/request-for-publish.js +++ b/tools/plugins/request-for-publish/request-for-publish.js @@ -277,7 +277,7 @@ class RequestForPublishPlugin extends LitElement { return html`
-

Loading…

+

Loading...

`; } From ee0a52697ff05a6709e8db5e09a1682433444069 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Mon, 20 Apr 2026 22:26:08 -0400 Subject: [PATCH 03/20] show a link to register site from plugin, show from addess and name fields for custom-api --- .../publish-requests-inbox.js | 56 ++++++++++++------- .../request-for-publish.js | 44 ++++++++++++++- tools/plugins/request-for-publish/utils.js | 18 ++++++ 3 files changed, 97 insertions(+), 21 deletions(-) diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index 30bdb81..4574b4b 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -234,6 +234,24 @@ class PublishRequestsApp extends LitElement { return; } + // Check site existence and registration before proceeding + const [siteExists, regStatus] = await Promise.all([ + checkSiteExists(this._org, this._site, this.token), + checkSiteRegistration(this._org, this._site, this.token), + ]); + this._registrationChecked = true; + this._siteRegistered = regStatus.registered; + + if (!siteExists) { + this._state = 'site-not-found'; + return; + } + + if (!regStatus.registered) { + this._state = 'unregistered'; + return; + } + await this.loadSiteSettings(this._org, this._site); // Sample RUM enhancer if the RUM script is loaded @@ -797,9 +815,9 @@ class PublishRequestsApp extends LitElement { } emailConfig.apiUrl = apiUrl; if (apiKey) emailConfig.apiKey = apiKey; + if (fromAddress) emailConfig.fromAddress = fromAddress; + if (fromName) emailConfig.fromName = fromName; } - if (fromAddress) emailConfig.fromAddress = fromAddress; - if (fromName) emailConfig.fromName = fromName; if (allowedEmailDomains.length > 0) emailConfig.allowedEmailDomains = allowedEmailDomains; const result = await registerSite(this._org, this._site, emailConfig, this.token); @@ -953,24 +971,6 @@ class PublishRequestsApp extends LitElement { ${this.renderCustomApiFields()} -
- - -
- -
- - -
-
+
+ + +
+
+ + +
`; } diff --git a/tools/plugins/request-for-publish/request-for-publish.js b/tools/plugins/request-for-publish/request-for-publish.js index 2a34a5d..6555bbc 100644 --- a/tools/plugins/request-for-publish/request-for-publish.js +++ b/tools/plugins/request-for-publish/request-for-publish.js @@ -10,6 +10,7 @@ import { withdrawPublishRequest, getUserEmail, checkExistingRequest, + checkSiteRegistration, } from './utils.js'; // Super Lite (sl-*) — Spectrum-aligned controls for DA; pairs with S2 tokens in CSS. @@ -66,6 +67,7 @@ class RequestForPublishPlugin extends LitElement { _commentsRequired: { state: true }, _commentsMinLength: { state: true }, _submitPhase: { state: true }, + _siteRegistered: { state: true }, }; constructor() { @@ -85,6 +87,7 @@ class RequestForPublishPlugin extends LitElement { this._commentsRequired = false; this._commentsMinLength = 10; this._submitPhase = ''; + this._siteRegistered = null; } connectedCallback() { @@ -123,14 +126,27 @@ class RequestForPublishPlugin extends LitElement { return `https://da.live/app/aemsites/da-blog-tools/tools/apps/publish-requests-inbox/publish-requests-inbox?org=${encodeURIComponent(org)}&site=${encodeURIComponent(site)}&requester=true`; } + get inboxAppUrl() { + const { org, repo: site } = this.context; + return `https://da.live/app/aemsites/da-blog-tools/tools/apps/publish-requests-inbox/publish-requests-inbox?org=${encodeURIComponent(org)}&site=${encodeURIComponent(site)}`; + } + async init() { this._isLoading = true; // Fetch user email from Adobe IMS profile this._userEmail = await getUserEmail(this.token); - // Detect approvers for this content path const { org, repo: site } = this.context; + + // Check if the site is registered for the publish workflow + this._siteRegistered = await checkSiteRegistration(org, site, this.token); + if (!this._siteRegistered) { + this._isLoading = false; + return; + } + + // Detect approvers for this content path const result = await resolveWorkflowConfig(this.contentPath, org, site, this.token); this._approvers = result.approvers || []; this._cc = result.cc || []; @@ -273,6 +289,28 @@ class RequestForPublishPlugin extends LitElement { } } + renderUnregistered() { + const { org, repo: site } = this.context; + return html` +
+
+ +
+

Site Not Registered

+

+ ${org}/${site} is not yet registered for the publish workflow. + A site administrator needs to register it before publish requests can be submitted. +

+

+ + + Go to Publish Requests Inbox to register this site + +

+
+ `; + } + renderLoading() { return html`
@@ -474,6 +512,10 @@ class RequestForPublishPlugin extends LitElement { return this.renderLoading(); } + if (this._siteRegistered === false) { + return this.renderUnregistered(); + } + if (this._withdrawn) { return this.renderWithdrawn(); } diff --git a/tools/plugins/request-for-publish/utils.js b/tools/plugins/request-for-publish/utils.js index b5c5c3a..497da56 100644 --- a/tools/plugins/request-for-publish/utils.js +++ b/tools/plugins/request-for-publish/utils.js @@ -596,6 +596,24 @@ export async function checkExistingRequest(org, site, path, requesterEmail, toke } } +/** + * Check whether org/site is registered in the worker's KV store. + * GET /api/site-config?org={org}&site={site} + * @param {string} org - Organization + * @param {string} site - Site + * @param {string} token - Authorization token + * @returns {Promise} true if registered, false otherwise + */ +export async function checkSiteRegistration(org, site, token) { + try { + const url = `${getWorkerUrl()}/api/site-config?org=${encodeURIComponent(org)}&site=${encodeURIComponent(site)}`; + const resp = await fetch(url, getOpts(token, 'GET')); + return resp.ok; + } catch { + return false; + } +} + /** * Fetch the current user's email from Adobe IMS profile * @param {string} token - The authorization token From 74223700a5ac0118f286fbb6d8ee5429bb9e144e Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Tue, 21 Apr 2026 09:56:00 -0400 Subject: [PATCH 04/20] UX improvement for App - no visible switch from Blue to theme override --- .../publish-requests-inbox.js | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index ed8e358..fa6c00c 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -93,7 +93,6 @@ class PublishRequestsApp extends LitElement { // My-requests mode: tracks per-path action ('resending' | 'withdrawing') _myRequestActions: { state: true }, // Toolbar - _siteSelectLoading: { state: true }, _orgSiteValue: { state: true }, // Inline reject error _rejectError: { state: true }, @@ -118,7 +117,6 @@ class PublishRequestsApp extends LitElement { this._approveAllProcessing = false; this._requester = false; this._myRequestActions = new Map(); - this._siteSelectLoading = false; this._orgSiteValue = ''; } @@ -355,7 +353,7 @@ class PublishRequestsApp extends LitElement { } const { org, site } = parsed; - this._siteSelectLoading = true; + this._state = 'loading'; if (!this._userEmail) { this._userEmail = await getUserEmail(this.token); @@ -363,7 +361,7 @@ class PublishRequestsApp extends LitElement { } if (this._needsEmail) { - this._siteSelectLoading = false; + this._state = 'idle'; this._message = { type: 'error', text: 'Unable to determine your email. Please log in to DA first.' }; return; } @@ -380,14 +378,12 @@ class PublishRequestsApp extends LitElement { if (this._requester) urlParams.requester = 'true'; this.updateUrl(urlParams); - // Load inbox inline + // Load inbox inline — these methods set _state to 'inbox'/'my-requests' when done if (this._requester) { await this.initMyRequests(); } else { await this.initInbox(); } - - this._siteSelectLoading = false; } // ======== Inline review — opens detail view without page navigation ======== @@ -771,9 +767,8 @@ class PublishRequestsApp extends LitElement { this.handleSiteSelect()} - ?disabled=${this._siteSelectLoading} > - ${this._siteSelectLoading ? 'Loading…' : primaryLabel} + ${primaryLabel}
@@ -1243,6 +1238,13 @@ class PublishRequestsApp extends LitElement { } render() { + // render nothing but the spinner until init() + // completes and the site theme (--pw-accent / --pw-accent-hover) is applied. + // This prevents a brief flash of the default blue theme on the toolbar + // button and action links before the site override takes effect. + if (this._state === 'loading') { + return this.renderLoading(); + } return html` ${this.renderToolbar()} ${this.renderMessage()} From 7e6986c0315f9fa0a9b82c0af8308bd8d7e6bb9e Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Wed, 22 Apr 2026 22:51:58 -0400 Subject: [PATCH 05/20] show email provider option only for sites from internal orgs --- tools/apps/publish-requests-inbox/api.js | 28 +++++++++--- .../publish-requests-inbox.js | 44 ++++++++++++++----- tools/plugins/request-for-publish/utils.js | 9 ++++ 3 files changed, 64 insertions(+), 17 deletions(-) diff --git a/tools/apps/publish-requests-inbox/api.js b/tools/apps/publish-requests-inbox/api.js index e127ddc..1dc92e0 100644 --- a/tools/apps/publish-requests-inbox/api.js +++ b/tools/apps/publish-requests-inbox/api.js @@ -859,21 +859,37 @@ export async function checkSiteExists(org, site, token) { /** * Check whether org--site is registered in the worker's KV store. * GET /api/site-config?org={org}&site={site} + * + * The worker returns different shapes depending on registration status: + * - Registered: 200 with the site config object + * - Not registered: 200 with { error, availableProviders: string[] } + * - Legacy fallback: 404 for unregistered (older worker versions) + * * @param {string} org - Organization * @param {string} site - Site * @param {string} token - Authorization token - * @returns {Promise<{registered: boolean, config: Object|null}>} + * @returns {Promise<{registered: boolean, config: Object|null, availableProviders: string[]}>} */ export async function checkSiteRegistration(org, site, token) { try { const url = `${getWorkerUrl()}/api/site-config?org=${encodeURIComponent(org)}&site=${encodeURIComponent(site)}`; const resp = await fetch(url, getOpts(token, 'GET')); - if (resp.status === 404) return { registered: false, config: null }; - if (!resp.ok) return { registered: false, config: null }; - const config = await resp.json(); - return { registered: true, config }; + let data; + try { data = await resp.json(); } catch { data = null; } + + if (data?.availableProviders) { + return { + registered: false, + config: null, + availableProviders: data.availableProviders, + }; + } + if (!resp.ok) { + return { registered: false, config: null, availableProviders: ['custom-api'] }; + } + return { registered: true, config: data, availableProviders: [] }; } catch { - return { registered: false, config: null }; + return { registered: false, config: null, availableProviders: ['custom-api'] }; } } diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index 4574b4b..ad63bc7 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -106,6 +106,7 @@ class PublishRequestsApp extends LitElement { _registrationChecked: { state: true }, _registerProcessing: { state: true }, _showRegisterForm: { state: true }, + _availableProviders: { state: true }, }; constructor() { @@ -133,6 +134,7 @@ class PublishRequestsApp extends LitElement { this._registrationChecked = false; this._registerProcessing = false; this._showRegisterForm = false; + this._availableProviders = []; } connectedCallback() { @@ -241,6 +243,7 @@ class PublishRequestsApp extends LitElement { ]); this._registrationChecked = true; this._siteRegistered = regStatus.registered; + this._availableProviders = regStatus.availableProviders || []; if (!siteExists) { this._state = 'site-not-found'; @@ -413,6 +416,7 @@ class PublishRequestsApp extends LitElement { ]); this._registrationChecked = true; this._siteRegistered = regStatus.registered; + this._availableProviders = regStatus.availableProviders || []; if (!siteExists) { this._siteSelectLoading = false; @@ -796,7 +800,11 @@ class PublishRequestsApp extends LitElement { this._message = null; const getVal = (id) => (this.shadowRoot.querySelector(`#${id}`)?.value ?? '').trim(); - const emailProvider = getVal('reg-email-provider') || 'default'; + const providers = this._availableProviders; + const showDropdown = providers.length > 1; + const emailProvider = showDropdown + ? (getVal('reg-email-provider') || providers[0]) + : (providers[0] || 'custom-api'); const apiUrl = getVal('reg-api-url'); const apiKey = getVal('reg-api-key'); const fromAddress = getVal('reg-from-address'); @@ -954,22 +962,30 @@ class PublishRequestsApp extends LitElement { } renderRegisterForm() { + const providers = this._availableProviders; + const showDropdown = providers.length > 1; + const onlyCustomApi = providers.length === 1 && providers[0] === 'custom-api'; + return html`

Registration Settings

Configure the email provider and notification settings for this site.

-
- - -
+ ${showDropdown ? html` +
+ + +
+ ` : nothing} - ${this.renderCustomApiFields()} + ${showDropdown ? this.renderCustomApiFields() : nothing} + ${onlyCustomApi ? this.renderCustomApiFieldsAlways() : nothing}
@@ -981,6 +997,8 @@ class PublishRequestsApp extends LitElement { Comma-separated list of domains allowed to receive notifications.
+ ${this.renderMessage()} +
@@ -1494,7 +1516,7 @@ class PublishRequestsApp extends LitElement { render() { return html` ${this.renderToolbar()} - ${this.renderMessage()} + ${this._showRegisterForm ? nothing : this.renderMessage()}
${this.renderContent()}
diff --git a/tools/plugins/request-for-publish/utils.js b/tools/plugins/request-for-publish/utils.js index 497da56..c317d83 100644 --- a/tools/plugins/request-for-publish/utils.js +++ b/tools/plugins/request-for-publish/utils.js @@ -599,6 +599,12 @@ export async function checkExistingRequest(org, site, path, requesterEmail, toke /** * Check whether org/site is registered in the worker's KV store. * GET /api/site-config?org={org}&site={site} + * + * The worker returns different shapes depending on registration status: + * - Registered: 200 with the site config object + * - Not registered: 200 with { error, availableProviders: string[] } + * - Legacy fallback: 404 for unregistered (older worker versions) + * * @param {string} org - Organization * @param {string} site - Site * @param {string} token - Authorization token @@ -608,6 +614,9 @@ export async function checkSiteRegistration(org, site, token) { try { const url = `${getWorkerUrl()}/api/site-config?org=${encodeURIComponent(org)}&site=${encodeURIComponent(site)}`; const resp = await fetch(url, getOpts(token, 'GET')); + let data; + try { data = await resp.json(); } catch { data = null; } + if (data?.availableProviders) return false; return resp.ok; } catch { return false; From e4f0d048f3d127514e4f7d6b3f071fc15def0b79 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Thu, 23 Apr 2026 11:25:30 -0400 Subject: [PATCH 06/20] conflicts resolved --- .../publish-requests-inbox.js | 37 ------------------- 1 file changed, 37 deletions(-) diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index bfb6452..44ebd0b 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -891,7 +891,6 @@ class PublishRequestsApp extends LitElement { class="pw-fill-accent site-select-submit" @click=${() => this.handleSiteSelect()} > -<<<<<<< HEAD ${this._siteSelectLoading ? 'Loading...' : primaryLabel}
@@ -1070,42 +1069,10 @@ class PublishRequestsApp extends LitElement { The site ${this._org}/${this._site} could not be found. Please check the organization and site names and try again.

-======= - ${primaryLabel} - -
->>>>>>> c432c446451a4fd6286d2275fbf5866915ffc19e `; } - renderContent() { - switch (this._state) { - case 'loading': - return this.renderLoading(); - case 'idle': - return nothing; - case 'error': - return this.renderError(); - case 'unauthorized': - return this.renderUnauthorized(); - case 'no-request': - return this.renderNoRequest(); - case 'inbox': - return this.renderInbox(); - case 'my-requests': - return this.renderMyRequests(); - case 'approved': - return this.renderApproved(); - case 'rejected': - return this.renderRejected(); - case 'review': - return this.renderReview(); - default: - return nothing; - } - } - // ======== Inbox renders ======== renderInbox() { @@ -1551,11 +1518,7 @@ class PublishRequestsApp extends LitElement { } return html` ${this.renderToolbar()} -<<<<<<< HEAD ${this._showRegisterForm ? nothing : this.renderMessage()} -======= - ${this.renderMessage()} ->>>>>>> c432c446451a4fd6286d2275fbf5866915ffc19e
${this.renderContent()}
From 18765667cb99a77540a7f1b98ba07f6ea9b817be Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Thu, 23 Apr 2026 15:38:10 -0400 Subject: [PATCH 07/20] added gmail support for internal orgs --- .../publish-requests-inbox.js | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index 44ebd0b..e3b021c 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -796,11 +796,7 @@ class PublishRequestsApp extends LitElement { this._message = null; const getVal = (id) => (this.shadowRoot.querySelector(`#${id}`)?.value ?? '').trim(); - const providers = this._availableProviders; - const showDropdown = providers.length > 1; - const emailProvider = showDropdown - ? (getVal('reg-email-provider') || providers[0]) - : (providers[0] || 'custom-api'); + const emailProvider = this.selectedProvider; const apiUrl = getVal('reg-api-url'); const apiKey = getVal('reg-api-key'); const fromAddress = getVal('reg-from-address'); @@ -834,13 +830,16 @@ class PublishRequestsApp extends LitElement { // Now proceed to load the inbox this._siteSelectLoading = true; - await this.loadSiteSettings(this._org, this._site); - if (this._requester) { - await this.initMyRequests(); - } else { - await this.initInbox(); + try { + await this.loadSiteSettings(this._org, this._site); + if (this._requester) { + await this.initMyRequests(); + } else { + await this.initInbox(); + } + } finally { + this._siteSelectLoading = false; } - this._siteSelectLoading = false; } else { this._message = { type: 'error', text: result.error }; } @@ -956,10 +955,25 @@ class PublishRequestsApp extends LitElement { `; } + getProviderLabel(provider) { + const labels = { + default: 'MailChannels (default)', + gmail: 'Gmail', + 'custom-api': 'Custom API', + }; + return labels[provider] || provider; + } + + get selectedProvider() { + const providers = this._availableProviders; + if (providers.length <= 1) return providers[0] || 'custom-api'; + const sel = this.shadowRoot?.querySelector('#reg-email-provider'); + return sel?.value || providers[0]; + } + renderRegisterForm() { const providers = this._availableProviders; const showDropdown = providers.length > 1; - const onlyCustomApi = providers.length === 1 && providers[0] === 'custom-api'; return html`
@@ -971,16 +985,15 @@ class PublishRequestsApp extends LitElement {
` : nothing} - ${showDropdown ? this.renderCustomApiFields() : nothing} - ${onlyCustomApi ? this.renderCustomApiFieldsAlways() : nothing} + ${this.selectedProvider === 'custom-api' ? this.renderCustomApiFieldsAlways() : nothing}
@@ -1013,12 +1026,6 @@ class PublishRequestsApp extends LitElement { `; } - renderCustomApiFields() { - const sel = this.shadowRoot?.querySelector('#reg-email-provider'); - if (!sel || sel.value !== 'custom-api') return nothing; - return this.renderCustomApiFieldsAlways(); - } - renderCustomApiFieldsAlways() { return html`
From f0006a54fe05e0840b1c01e5f34426b63cfd2c66 Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Fri, 24 Apr 2026 14:05:55 -0400 Subject: [PATCH 08/20] Tidy publish-requests inbox unregistered-site UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hide the "Publish Request Inbox" header and org/site input on the unregistered-site screen; the registration form is the only useful action there, so keeping the toolbar around just adds noise. - Rename the "MailChannels (default)" provider option to "Default" — the underlying transport is an implementation detail (and is now AWS SES anyway), not something authors need to see. - Drop the now-stale "Gmail" provider label; the worker no longer advertises gmail, so authors will never see it in the dropdown. Made-with: Cursor --- tools/apps/publish-requests-inbox/publish-requests-inbox.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index e3b021c..50892c5 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -957,8 +957,7 @@ class PublishRequestsApp extends LitElement { getProviderLabel(provider) { const labels = { - default: 'MailChannels (default)', - gmail: 'Gmail', + default: 'Default', 'custom-api': 'Custom API', }; return labels[provider] || provider; @@ -1523,8 +1522,9 @@ class PublishRequestsApp extends LitElement { if (this._state === 'loading') { return this.renderLoading(); } + const hideToolbar = this._state === 'unregistered'; return html` - ${this.renderToolbar()} + ${hideToolbar ? nothing : this.renderToolbar()} ${this._showRegisterForm ? nothing : this.renderMessage()}
${this.renderContent()} From 940afda2e80b7a469a50f1f733842024a7c66f5d Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Fri, 24 Apr 2026 15:19:20 -0400 Subject: [PATCH 09/20] Fix stuck "Loading..." button + reorder post-register banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small inbox UX fixes following the self-serve registration flow: 1. The toolbar's primary button stayed at "Loading..." after a successful registration. Cause: _siteSelectLoading was being mutated in handleRegister (true → finally → false) but it was never declared as a Lit reactive state property and never initialised in the constructor. handleRegister's final _siteSelectLoading = false therefore did not trigger a re-render, so the toolbar was last drawn while it was still true. Add it to the static properties block and initialise it to false. 2. After registering a site the success banner ("Site .../... registered successfully!") appeared between the "Publish Request Inbox" title and the inbox content, which buries the most important confirmation. Render the message above the toolbar so it sits at the very top of the view. Made-with: Cursor --- tools/apps/publish-requests-inbox/publish-requests-inbox.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index 50892c5..afea942 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -98,6 +98,7 @@ class PublishRequestsApp extends LitElement { _myRequestActions: { state: true }, // Toolbar _orgSiteValue: { state: true }, + _siteSelectLoading: { state: true }, // Inline reject error _rejectError: { state: true }, // Registration @@ -128,6 +129,7 @@ class PublishRequestsApp extends LitElement { this._requester = false; this._myRequestActions = new Map(); this._orgSiteValue = ''; + this._siteSelectLoading = false; this._siteRegistered = null; this._registrationChecked = false; this._registerProcessing = false; @@ -1524,8 +1526,8 @@ class PublishRequestsApp extends LitElement { } const hideToolbar = this._state === 'unregistered'; return html` - ${hideToolbar ? nothing : this.renderToolbar()} ${this._showRegisterForm ? nothing : this.renderMessage()} + ${hideToolbar ? nothing : this.renderToolbar()}
${this.renderContent()}
From 550acc59667225ddc10d0a06bda039cad202b239 Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Fri, 24 Apr 2026 15:42:57 -0400 Subject: [PATCH 10/20] Show registration form directly on the unregistered screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the unregistered-site view rendered a "Site Not Registered" banner with a "Register Site" button, and clicking it expanded the registration form below. The intermediate click was busywork — a user who has navigated to the inbox for an unregistered site is, by definition, here to register it. Drop the toggle entirely: - Render the registration form unconditionally inside renderUnregistered. - Remove the "Register Site" expand button and the form's "Cancel" button (there is no intermediate state left to fall back to; users can change the org/site input or navigate away). - Remove the _showRegisterForm reactive prop, its constructor init, the toggleRegisterForm() method, and all its assignment sites. - Re-key the global-message suppression on _state === 'unregistered' instead of _showRegisterForm — the registration form still has its own inline renderMessage() so we don't want a duplicate at the top while the user is filling it out. Made-with: Cursor --- .../publish-requests-inbox.js | 27 +++---------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index afea942..92b2bf0 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -105,7 +105,6 @@ class PublishRequestsApp extends LitElement { _siteRegistered: { state: true }, _registrationChecked: { state: true }, _registerProcessing: { state: true }, - _showRegisterForm: { state: true }, _availableProviders: { state: true }, }; @@ -133,7 +132,6 @@ class PublishRequestsApp extends LitElement { this._siteRegistered = null; this._registrationChecked = false; this._registerProcessing = false; - this._showRegisterForm = false; this._availableProviders = []; } @@ -376,7 +374,6 @@ class PublishRequestsApp extends LitElement { async handleSiteSelect(e) { if (e) e.preventDefault(); this._message = null; - this._showRegisterForm = false; const input = this.shadowRoot.querySelector('#org-site'); const orgSite = (input?.value ?? '').trim(); @@ -789,10 +786,6 @@ class PublishRequestsApp extends LitElement { // ======== Registration handlers ======== - toggleRegisterForm() { - this._showRegisterForm = !this._showRegisterForm; - } - async handleRegister() { this._registerProcessing = true; this._message = null; @@ -827,7 +820,6 @@ class PublishRequestsApp extends LitElement { if (result.success) { this._siteRegistered = true; - this._showRegisterForm = false; this._message = { type: 'success', text: `Site ${this._org}/${this._site} registered successfully!` }; // Now proceed to load the inbox @@ -944,15 +936,9 @@ class PublishRequestsApp extends LitElement { ${this._org}/${this._site} is not yet registered for the publish workflow. Register it to enable publish request approvals and email notifications.

- ${!this._showRegisterForm ? html` - this.toggleRegisterForm()} - >Register Site - ` : nothing}
- ${this._showRegisterForm ? this.renderRegisterForm() : nothing} + ${this.renderRegisterForm()}
`; } @@ -1016,11 +1002,6 @@ class PublishRequestsApp extends LitElement { > ${this._registerProcessing ? 'Registering...' : 'Register'} - this.toggleRegisterForm()} - ?disabled=${this._registerProcessing} - >Cancel
@@ -1524,10 +1505,10 @@ class PublishRequestsApp extends LitElement { if (this._state === 'loading') { return this.renderLoading(); } - const hideToolbar = this._state === 'unregistered'; + const isUnregistered = this._state === 'unregistered'; return html` - ${this._showRegisterForm ? nothing : this.renderMessage()} - ${hideToolbar ? nothing : this.renderToolbar()} + ${isUnregistered ? nothing : this.renderMessage()} + ${isUnregistered ? nothing : this.renderToolbar()}
${this.renderContent()}
From 5c259ef534b3b04aa809eb59061d207a1c58a0df Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Fri, 24 Apr 2026 16:26:15 -0400 Subject: [PATCH 11/20] Add Step 2 DA configuration check on app load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the registration check passes, verify the DA-side workflow setup (library tab, apps tab, publish-workflow-config tab) before loading the inbox. The publish-workflow-config tab is required and blocks with a new full-page "DA Configuration Required" view that lists each tab's status, links to the setup docs, and offers an inline re-check. Library/apps tab statuses are tracked and surfaced inside that view but do not block on their own — they affect the author plugin flow rather than the approver inbox. Made-with: Cursor --- tools/apps/publish-requests-inbox/api.js | 115 +++++++++++++ .../publish-requests-inbox.css | 56 ++++++ .../publish-requests-inbox.js | 160 +++++++++++++++++- 3 files changed, 327 insertions(+), 4 deletions(-) diff --git a/tools/apps/publish-requests-inbox/api.js b/tools/apps/publish-requests-inbox/api.js index 1dc92e0..3e321c4 100644 --- a/tools/apps/publish-requests-inbox/api.js +++ b/tools/apps/publish-requests-inbox/api.js @@ -893,6 +893,121 @@ export async function checkSiteRegistration(org, site, token) { } } +/** + * Inspect a DA config object for the `library` tab and report whether a row + * pointing at the Request Publish plugin is present. Path matching is a + * case-insensitive suffix match on the canonical plugin location, which + * keeps the check robust across forks (org/repo) of da-blog-tools. + * @param {Object} config - DA config object + * @returns {'ok'|'missing'} status of the library tab plugin entry + */ +function inspectLibraryTab(config) { + const rows = config?.library?.data; + if (!Array.isArray(rows)) return 'missing'; + const needle = '/tools/plugins/request-for-publish/request-for-publish.html'; + const hasRow = rows.some((row) => { + const p = (row?.path || row?.Path || '').toLowerCase(); + return p.endsWith(needle); + }); + return hasRow ? 'ok' : 'missing'; +} + +/** + * Inspect a DA config object for the `apps` tab and report whether a row + * pointing at the Publish Requests Inbox app is present. Uses a suffix match + * on the canonical app path so the check works across forks. + * @param {Object} config - DA config object + * @returns {'ok'|'missing'} status of the apps tab inbox entry + */ +function inspectAppsTab(config) { + const rows = config?.apps?.data; + if (!Array.isArray(rows)) return 'missing'; + const needle = '/tools/apps/publish-requests-inbox/publish-requests-inbox'; + const hasRow = rows.some((row) => { + const p = (row?.path || row?.Path || '').toLowerCase(); + return p.endsWith(needle); + }); + return hasRow ? 'ok' : 'missing'; +} + +/** + * Inspect a DA config object for the `publish-workflow-config` tab and + * report whether at least one approver rule row is present. + * @param {Object} config - DA config object + * @returns {'ok'|'missing'} status of the workflow config tab + */ +function inspectWorkflowConfigTab(config) { + const rows = config?.['publish-workflow-config']?.data; + if (!Array.isArray(rows) || rows.length === 0) return 'missing'; + return 'ok'; +} + +/** + * Check that the required and recommended DA configuration tabs are set up + * for the Request Publish workflow. Mirrors the setup steps in + * https://docs.da.live/about/early-access/request-publish (Step 2). + * + * Reads the site-level DA config first + * (`GET https://admin.da.live/config/{org}/{site}/`) and merges any tabs + * still missing from the org-level config (`/config/{org}/`). This matches + * the documented "site-first, org fallback" behavior, but at the per-tab + * level so a tab present at either level counts as configured. + * + * @param {string} org - Organization + * @param {string} site - Site + * @param {string} token - Authorization token + * @returns {Promise<{ + * workflowConfig: 'ok'|'missing', + * library: 'ok'|'missing', + * apps: 'ok'|'missing', + * error: string|null, + * }>} + */ +export async function checkDaConfiguration(org, site, token) { + const headers = { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }; + + const fetchConfig = async (url) => { + try { + const resp = await fetch(url, { headers }); + if (!resp.ok) return null; + return await resp.json(); + } catch { + return null; + } + }; + + const [siteConfig, orgConfig] = await Promise.all([ + fetchConfig(`${DA_ADMIN}/config/${org}/${site}/`), + fetchConfig(`${DA_ADMIN}/config/${org}/`), + ]); + + // Per-tab status: a tab is OK if present at site OR org level. + const promote = (siteStatus, orgStatus) => ( + siteStatus === 'ok' || orgStatus === 'ok' ? 'ok' : 'missing' + ); + + const result = { + workflowConfig: promote( + inspectWorkflowConfigTab(siteConfig), + inspectWorkflowConfigTab(orgConfig), + ), + library: promote(inspectLibraryTab(siteConfig), inspectLibraryTab(orgConfig)), + apps: promote(inspectAppsTab(siteConfig), inspectAppsTab(orgConfig)), + error: null, + }; + + // If neither config request returned anything, surface that distinct error + // — most often a permissions issue on the DA config sheet. + if (!siteConfig && !orgConfig) { + result.error = 'Could not read DA configuration. You may not have access to the config sheet for this site.'; + } + + return result; +} + /** * Register a new org--site in the worker's KV store with email provider config. * POST /api/site-config diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.css b/tools/apps/publish-requests-inbox/publish-requests-inbox.css index 93e759a..8d8816b 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.css +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.css @@ -781,6 +781,62 @@ sl-button.pw-quiet-secondary::part(base):hover:not(:disabled) { text-align: left; } +/* ========== DA configuration status (Step 2 of onboarding) ========== */ +.config-status-list { + list-style: none; + margin: var(--spacing-200) 0 var(--spacing-300); + padding: 0; +} + +.config-status-row { + display: flex; + align-items: center; + gap: var(--spacing-200); + padding: var(--spacing-100) 0; + border-bottom: 1px solid var(--s2-gray-200); + font-size: var(--s2-body-s-size); + color: var(--s2-gray-800); +} + +.config-status-row:last-child { + border-bottom: none; +} + +.config-status-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + flex-shrink: 0; +} + +.config-status-icon svg { + width: 14px; + height: 14px; +} + +.config-status-icon--ok { + background: var(--s2-green-100); + color: var(--s2-green-900); +} + +.config-status-icon--missing { + background: var(--s2-red-100); + color: var(--s2-red-900); +} + +.config-status-label { + flex: 1; + min-width: 0; +} + +.config-status-text { + font-weight: 600; + color: var(--s2-gray-700); +} + .register-form { display: flex; flex-direction: column; diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index 92b2bf0..5e1f0ce 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -21,9 +21,12 @@ import { fetchAccentSettings, checkSiteExists, checkSiteRegistration, + checkDaConfiguration, registerSite, } from './api.js'; +const REQUEST_PUBLISH_DOCS_URL = 'https://docs.da.live/about/early-access/request-publish'; + // Super Lite (sl-*) — Spectrum-aligned controls for DA; pairs with S2 tokens in CSS. // NX style pipeline matches other da.live shell apps (e.g. MSM): nexter.js loadStyle + getStyle. const NX = 'https://da.live/nx'; @@ -75,7 +78,7 @@ class PublishRequestsApp extends LitElement { token: { attribute: false }, // view states: 'loading', 'idle', 'inbox', 'review', 'approved', // 'rejected', 'error', 'unauthorized', 'no-request', - // 'site-not-found', 'unregistered' + // 'site-not-found', 'unregistered', 'config-missing' _state: { state: true }, _isProcessing: { state: true }, _message: { state: true }, @@ -106,6 +109,9 @@ class PublishRequestsApp extends LitElement { _registrationChecked: { state: true }, _registerProcessing: { state: true }, _availableProviders: { state: true }, + // DA configuration check (Step 2 of onboarding) + _daConfig: { state: true }, + _daConfigRechecking: { state: true }, }; constructor() { @@ -133,6 +139,8 @@ class PublishRequestsApp extends LitElement { this._registrationChecked = false; this._registerProcessing = false; this._availableProviders = []; + this._daConfig = null; + this._daConfigRechecking = false; } connectedCallback() { @@ -187,6 +195,32 @@ class PublishRequestsApp extends LitElement { return `${this.appBaseUrl}?${params.toString()}`; } + /** + * Step 2 of onboarding: verify the DA-side configuration is in place for + * this org/site (publish-workflow-config required, library/apps tabs + * informational). Returns true when the workflow can proceed, false when + * the caller should stop and let the `'config-missing'` view render. + * + * Called after the registration check passes so we never block on DA + * config for a site that's also unregistered. + * + * Library/apps tab statuses are tracked in `_daConfig` and surfaced inside + * the `renderConfigMissing` view, but they do not block the inbox view on + * their own. Their absence only matters to the author-side plugin flow, + * not to the approver inbox we're loading here. + */ + async checkDaConfig(org, site) { + const status = await checkDaConfiguration(org, site, this.token); + this._daConfig = status; + + if (status.workflowConfig === 'missing') { + this._state = 'config-missing'; + return false; + } + + return true; + } + async loadSiteSettings(org, site) { try { const [siteConfig, accentSettings] = await Promise.all([ @@ -253,6 +287,12 @@ class PublishRequestsApp extends LitElement { return; } + // Step 2: DA configuration check. Stop here if the required + // `publish-workflow-config` tab is missing — `checkDaConfig` will have + // set `_state` to `'config-missing'` for us. + const daConfigOk = await this.checkDaConfig(this._org, this._site); + if (!daConfigOk) return; + await this.loadSiteSettings(this._org, this._site); // Sample RUM enhancer if the RUM script is loaded @@ -433,6 +473,16 @@ class PublishRequestsApp extends LitElement { return; } + // Step 2: DA configuration check. + const daConfigOk = await this.checkDaConfig(org, site); + if (!daConfigOk) { + this._siteSelectLoading = false; + const urlParams = { org, site }; + if (this._requester) urlParams.requester = 'true'; + this.updateUrl(urlParams); + return; + } + await this.loadSiteSettings(org, site); const urlParams = { org, site }; @@ -825,6 +875,12 @@ class PublishRequestsApp extends LitElement { // Now proceed to load the inbox this._siteSelectLoading = true; try { + // Step 2: DA configuration check. A freshly-registered site is + // very likely to not have its DA config set up yet, so this is + // the natural place to surface the next setup step. + const daConfigOk = await this.checkDaConfig(this._org, this._site); + if (!daConfigOk) return; + await this.loadSiteSettings(this._org, this._site); if (this._requester) { await this.initMyRequests(); @@ -907,6 +963,8 @@ class PublishRequestsApp extends LitElement { return this.renderSiteNotFound(); case 'unregistered': return this.renderUnregistered(); + case 'config-missing': + return this.renderConfigMissing(); case 'inbox': return this.renderInbox(); case 'my-requests': @@ -1062,6 +1120,97 @@ class PublishRequestsApp extends LitElement { `; } + // ======== Config missing (DA setup) render ======== + + /** + * Re-run the DA configuration check without reloading the page. If the + * required tab is now in place, fall through to the normal post-check + * flow (load settings → inbox or my-requests). + */ + async handleRecheckConfig() { + if (this._daConfigRechecking) return; + this._daConfigRechecking = true; + try { + const ok = await this.checkDaConfig(this._org, this._site); + if (!ok) return; + + this._state = 'loading'; + await this.loadSiteSettings(this._org, this._site); + if (this._requester) { + await this.initMyRequests(); + } else { + await this.initInbox(); + } + } finally { + this._daConfigRechecking = false; + } + } + + renderConfigStatusRow(label, status) { + const ok = status === 'ok'; + const iconClass = ok ? 'config-status-icon--ok' : 'config-status-icon--missing'; + const text = ok ? 'Configured' : 'Not configured'; + const icon = ok + ? html`` + : html``; + return html` +
  • + + ${label} + ${text} +
  • + `; + } + + renderConfigMissing() { + const status = this._daConfig || { workflowConfig: 'missing', library: 'missing', apps: 'missing' }; + const fetchError = status.error; + + return html` +
    +
    +
    + +
    +

    DA Configuration Required

    +

    + ${this._org}/${this._site} is registered, but the + DA-side setup for the Request Publish workflow isn't complete yet. + Finish the steps below in your DA config sheet, then re-check. +

    +
    + +
    +

    Configuration status

    + ${fetchError ? html`

    ${fetchError}

    ` : nothing} +
      + ${this.renderConfigStatusRow('publish-workflow-config tab (required)', status.workflowConfig)} + ${this.renderConfigStatusRow('library tab — Request Publish plugin', status.library)} + ${this.renderConfigStatusRow('apps tab — Publish Requests Inbox', status.apps)} +
    +

    + See the + Request Publish setup guide + for the exact rows to add to each tab. The + publish-workflow-config tab is required — without it + the workflow has no approver rules to route requests through. The + library and apps tabs are needed for + authors to discover the plugin and this app from DA. +

    +
    + this.handleRecheckConfig()} + ?disabled=${this._daConfigRechecking} + > + ${this._daConfigRechecking ? 'Re-checking...' : 'Re-check configuration'} + +
    +
    +
    + `; + } + // ======== Inbox renders ======== renderInbox() { @@ -1505,10 +1654,13 @@ class PublishRequestsApp extends LitElement { if (this._state === 'loading') { return this.renderLoading(); } - const isUnregistered = this._state === 'unregistered'; + // Hide the org/site toolbar during the onboarding states (registration + // and DA configuration). The full-page status view owns the screen + // until the user has finished those setup steps. + const hideToolbar = this._state === 'unregistered' || this._state === 'config-missing'; return html` - ${isUnregistered ? nothing : this.renderMessage()} - ${isUnregistered ? nothing : this.renderToolbar()} + ${hideToolbar ? nothing : this.renderMessage()} + ${hideToolbar ? nothing : this.renderToolbar()}
    ${this.renderContent()}
    From 256ef730b7e4f5b76172a47207a8f927c656c6d2 Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Mon, 27 Apr 2026 11:55:43 -0400 Subject: [PATCH 12/20] Add toolbar Setup status pill + extend DA config check to all 5 tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspect publish-workflow-settings and publish-workflow-groups-to-email alongside the existing required tabs, and surface the full picture through a small "Setup" pill in the toolbar that opens a lazy panel listing each tab's status with site/org provenance. Smart status states avoid false alarms: - workflow-settings: 'default' (tab absent → using documented defaults) vs 'empty' (tab present, zero rows → likely authoring mistake) - groups-to-email: 'not-needed' when no distribution list names appear in publish-workflow-config approvers/CC, otherwise 'ok' or 'missing' Refactor the DA-config fetch path to halve boot fetches for happy-path users: a single fetchSiteAndOrgConfig call powers both the setup check and accent-color extraction (previously fetched twice in parallel). Made-with: Cursor --- tools/apps/publish-requests-inbox/api.js | 291 ++++++++++++------ .../publish-requests-inbox.css | 139 +++++++++ .../publish-requests-inbox.js | 209 +++++++++++-- 3 files changed, 508 insertions(+), 131 deletions(-) diff --git a/tools/apps/publish-requests-inbox/api.js b/tools/apps/publish-requests-inbox/api.js index 3e321c4..6b2f744 100644 --- a/tools/apps/publish-requests-inbox/api.js +++ b/tools/apps/publish-requests-inbox/api.js @@ -151,41 +151,56 @@ function resolveApproversWithGroups(approversList, groupsData) { } /** - * Fetch the workflow config via the DA Config API. - * Tries site-level first: GET /config/{org}/{site}/publish-workflow-config - * Falls back to org-level: GET /config/{org}/publish-workflow-config - * See: https://docs.da.live/developers/api/config#get-config + * Fetch site-level and org-level DA configs in parallel. This is the single + * source of truth for hitting `admin.da.live/config/...` from this module — + * higher-level helpers (`checkDaConfiguration`, `extractAccentSettings`, + * `fetchWorkflowConfig`) all derive from the configs returned here so the + * inbox app pays for at most two GETs at boot regardless of how many + * downstream questions it asks of the config. + * + * Network failures and non-200 responses become `null` for that level so + * callers can continue with the level that did return. + * * @param {string} org - Organization * @param {string} site - Site * @param {string} token - Authorization token - * @returns {Promise} Config data or null on failure + * @returns {Promise<{siteConfig: Object|null, orgConfig: Object|null}>} */ -async function fetchWorkflowConfig(org, site, token) { +async function fetchSiteAndOrgConfig(org, site, token) { const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json', }; - - // 1. Try site-level root config - const siteUrl = `${DA_ADMIN}/config/${org}/${site}/`; - const siteResp = await fetch(siteUrl, { headers }); - if (siteResp.ok) { - const config = await siteResp.json(); - if (config['publish-workflow-config']) { - return config; - } - } - - // 2. Fallback to org-level root config - const orgUrl = `${DA_ADMIN}/config/${org}/`; - const orgResp = await fetch(orgUrl, { headers }); - if (orgResp.ok) { - const config = await orgResp.json(); - if (config['publish-workflow-config']) { - return config; + const fetchOne = async (url) => { + try { + const resp = await fetch(url, { headers }); + if (!resp.ok) return null; + return await resp.json(); + } catch { + return null; } - } + }; + const [siteConfig, orgConfig] = await Promise.all([ + fetchOne(`${DA_ADMIN}/config/${org}/${site}/`), + fetchOne(`${DA_ADMIN}/config/${org}/`), + ]); + return { siteConfig, orgConfig }; +} +/** + * Fetch the workflow config via the DA Config API. + * Tries site-level first: GET /config/{org}/{site}/publish-workflow-config + * Falls back to org-level: GET /config/{org}/publish-workflow-config + * See: https://docs.da.live/developers/api/config#get-config + * @param {string} org - Organization + * @param {string} site - Site + * @param {string} token - Authorization token + * @returns {Promise} Config data or null on failure + */ +async function fetchWorkflowConfig(org, site, token) { + const { siteConfig, orgConfig } = await fetchSiteAndOrgConfig(org, site, token); + if (siteConfig?.['publish-workflow-config']) return siteConfig; + if (orgConfig?.['publish-workflow-config']) return orgConfig; return null; } @@ -244,6 +259,30 @@ export async function fetchAccentSettings(org, site, token) { }; } +/** + * Synchronous accent-color extractor for callers that already have the DA + * configs in hand (e.g. the inbox app's `_daConfig` cache). Avoids a second + * round trip to `admin.da.live` on app boot when the config has already + * been fetched for the Step 2 setup check. + * + * Mirrors the precedence in `fetchWorkflowConfig`: prefer settings from + * whichever config has the `publish-workflow-settings` tab, site first. + * + * @param {Object|null} siteConfig - Site-level DA config (or null) + * @param {Object|null} orgConfig - Org-level DA config (or null) + * @returns {{accentColor: string|null, accentColorHover: string|null}} + */ +export function extractAccentSettings(siteConfig, orgConfig) { + let config = null; + if (siteConfig?.['publish-workflow-settings']) config = siteConfig; + else if (orgConfig?.['publish-workflow-settings']) config = orgConfig; + if (!config) return { accentColor: null, accentColorHover: null }; + return { + accentColor: extractSetting(config, 'theme.accent-color'), + accentColorHover: extractSetting(config, 'theme.accent-color-hover'), + }; +} + /** * Publish content via Helix Admin API * POST https://admin.hlx.page/live/{org}/{site}/main/{path} @@ -893,15 +932,11 @@ export async function checkSiteRegistration(org, site, token) { } } -/** - * Inspect a DA config object for the `library` tab and report whether a row - * pointing at the Request Publish plugin is present. Path matching is a - * case-insensitive suffix match on the canonical plugin location, which - * keeps the check robust across forks (org/repo) of da-blog-tools. - * @param {Object} config - DA config object - * @returns {'ok'|'missing'} status of the library tab plugin entry - */ -function inspectLibraryTab(config) { +// Per-tab inspectors. Each returns 'ok' or 'missing' for a single config +// object — provenance promotion (site-level vs. org-level) is layered on +// top by `inspectAtBothLevels` below. + +function inspectLibraryAt(config) { const rows = config?.library?.data; if (!Array.isArray(rows)) return 'missing'; const needle = '/tools/plugins/request-for-publish/request-for-publish.html'; @@ -912,14 +947,7 @@ function inspectLibraryTab(config) { return hasRow ? 'ok' : 'missing'; } -/** - * Inspect a DA config object for the `apps` tab and report whether a row - * pointing at the Publish Requests Inbox app is present. Uses a suffix match - * on the canonical app path so the check works across forks. - * @param {Object} config - DA config object - * @returns {'ok'|'missing'} status of the apps tab inbox entry - */ -function inspectAppsTab(config) { +function inspectAppsAt(config) { const rows = config?.apps?.data; if (!Array.isArray(rows)) return 'missing'; const needle = '/tools/apps/publish-requests-inbox/publish-requests-inbox'; @@ -930,82 +958,151 @@ function inspectAppsTab(config) { return hasRow ? 'ok' : 'missing'; } -/** - * Inspect a DA config object for the `publish-workflow-config` tab and - * report whether at least one approver rule row is present. - * @param {Object} config - DA config object - * @returns {'ok'|'missing'} status of the workflow config tab - */ -function inspectWorkflowConfigTab(config) { +function inspectWorkflowConfigAt(config) { const rows = config?.['publish-workflow-config']?.data; if (!Array.isArray(rows) || rows.length === 0) return 'missing'; return 'ok'; } +/** + * Promote a single-config inspector to a (site, org) pair, returning a + * status object with provenance. Site wins when both have it. + * @param {Object|null} siteConfig + * @param {Object|null} orgConfig + * @param {(c: Object|null) => 'ok'|'missing'} inspectAt + * @returns {{state: 'ok'|'missing', source: 'site'|'org'|null}} + */ +function inspectAtBothLevels(siteConfig, orgConfig, inspectAt) { + if (inspectAt(siteConfig) === 'ok') return { state: 'ok', source: 'site' }; + if (inspectAt(orgConfig) === 'ok') return { state: 'ok', source: 'org' }; + return { state: 'missing', source: null }; +} + +/** + * Inspect the optional `publish-workflow-settings` tab. Distinguishes: + * - 'configured': tab exists with at least one row + * - 'empty': tab exists but has no rows (likely an authoring mistake) + * - 'default': tab is absent — workflow runs with documented defaults + * + * @returns {{state: 'configured'|'empty'|'default', source: 'site'|'org'|null}} + */ +function inspectWorkflowSettings(siteConfig, orgConfig) { + const inspect = (config) => { + const rows = config?.['publish-workflow-settings']?.data; + if (!Array.isArray(rows)) return null; + return rows.length > 0 ? 'configured' : 'empty'; + }; + const siteState = inspect(siteConfig); + if (siteState) return { state: siteState, source: 'site' }; + const orgState = inspect(orgConfig); + if (orgState) return { state: orgState, source: 'org' }; + return { state: 'default', source: null }; +} + +/** + * Detect whether `publish-workflow-config` rows reference any distribution + * list (DL) names. A non-email entry in approvers/CC means a DL — which in + * turn means `publish-workflow-groups-to-email` must exist for the workflow + * to resolve recipients. If everything is plain emails, that tab is + * genuinely unnecessary. + * + * @param {Array} rows - publish-workflow-config rows + * @returns {boolean} + */ +function workflowConfigUsesDistributionLists(rows) { + if (!Array.isArray(rows)) return false; + for (const row of rows) { + const approvers = (row.approvers || row.Approvers || '').split(','); + const cc = (row.cc || row.CC || '').split(','); + for (const entry of [...approvers, ...cc]) { + const trimmed = entry.trim(); + if (trimmed && !trimmed.includes('@')) return true; + } + } + return false; +} + +/** + * Inspect the optional `publish-workflow-groups-to-email` tab. Smart about + * whether it's actually needed for this site's workflow rules: + * - 'ok': tab is present and populated + * - 'missing': workflow rules use DL names but the tab is absent/empty + * - 'not-needed': workflow rules use only plain email addresses, so no + * DL→email mapping is required + * + * @returns {{state: 'ok'|'missing'|'not-needed', source: 'site'|'org'|null}} + */ +function inspectGroupsToEmail(siteConfig, orgConfig) { + const wfRows = siteConfig?.['publish-workflow-config']?.data + || orgConfig?.['publish-workflow-config']?.data + || []; + if (!workflowConfigUsesDistributionLists(wfRows)) { + return { state: 'not-needed', source: null }; + } + const inspect = (config) => { + const rows = config?.['publish-workflow-groups-to-email']?.data; + return Array.isArray(rows) && rows.length > 0; + }; + if (inspect(siteConfig)) return { state: 'ok', source: 'site' }; + if (inspect(orgConfig)) return { state: 'ok', source: 'org' }; + return { state: 'missing', source: null }; +} + /** * Check that the required and recommended DA configuration tabs are set up * for the Request Publish workflow. Mirrors the setup steps in * https://docs.da.live/about/early-access/request-publish (Step 2). * - * Reads the site-level DA config first - * (`GET https://admin.da.live/config/{org}/{site}/`) and merges any tabs - * still missing from the org-level config (`/config/{org}/`). This matches - * the documented "site-first, org fallback" behavior, but at the per-tab - * level so a tab present at either level counts as configured. + * Reads site-level config first and falls back per-tab to the org-level + * config — matching the documented "site-first, org fallback" behavior at + * the per-tab granularity. The site/org configs themselves are returned + * alongside the status so the inbox app can derive other settings (e.g. + * accent colors via `extractAccentSettings`) without re-fetching. * - * @param {string} org - Organization - * @param {string} site - Site - * @param {string} token - Authorization token + * Status values per tab: + * - workflowConfig: { state: 'ok'|'missing', source } + * - library: { state: 'ok'|'missing', source } + * - apps: { state: 'ok'|'missing', source } + * - workflowSettings: { state: 'configured'|'empty'|'default', source } + * - groupsToEmail: { state: 'ok'|'missing'|'not-needed', source } + * + * Only `workflowConfig.state === 'missing'` is treated as a hard onboarding + * block by the inbox app — the rest are surfaced for transparency in the + * Setup status panel. + * + * @param {string} org + * @param {string} site + * @param {string} token * @returns {Promise<{ - * workflowConfig: 'ok'|'missing', - * library: 'ok'|'missing', - * apps: 'ok'|'missing', + * status: Object, + * siteConfig: Object|null, + * orgConfig: Object|null, * error: string|null, * }>} */ export async function checkDaConfiguration(org, site, token) { - const headers = { - Authorization: `Bearer ${token}`, - Accept: 'application/json', - }; - - const fetchConfig = async (url) => { - try { - const resp = await fetch(url, { headers }); - if (!resp.ok) return null; - return await resp.json(); - } catch { - return null; - } - }; - - const [siteConfig, orgConfig] = await Promise.all([ - fetchConfig(`${DA_ADMIN}/config/${org}/${site}/`), - fetchConfig(`${DA_ADMIN}/config/${org}/`), - ]); - - // Per-tab status: a tab is OK if present at site OR org level. - const promote = (siteStatus, orgStatus) => ( - siteStatus === 'ok' || orgStatus === 'ok' ? 'ok' : 'missing' - ); - - const result = { - workflowConfig: promote( - inspectWorkflowConfigTab(siteConfig), - inspectWorkflowConfigTab(orgConfig), - ), - library: promote(inspectLibraryTab(siteConfig), inspectLibraryTab(orgConfig)), - apps: promote(inspectAppsTab(siteConfig), inspectAppsTab(orgConfig)), - error: null, + const { siteConfig, orgConfig } = await fetchSiteAndOrgConfig(org, site, token); + + const status = { + workflowConfig: inspectAtBothLevels(siteConfig, orgConfig, inspectWorkflowConfigAt), + library: inspectAtBothLevels(siteConfig, orgConfig, inspectLibraryAt), + apps: inspectAtBothLevels(siteConfig, orgConfig, inspectAppsAt), + workflowSettings: inspectWorkflowSettings(siteConfig, orgConfig), + groupsToEmail: inspectGroupsToEmail(siteConfig, orgConfig), }; // If neither config request returned anything, surface that distinct error // — most often a permissions issue on the DA config sheet. - if (!siteConfig && !orgConfig) { - result.error = 'Could not read DA configuration. You may not have access to the config sheet for this site.'; - } + const error = !siteConfig && !orgConfig + ? 'Could not read DA configuration. You may not have access to the config sheet for this site.' + : null; - return result; + return { + status, + siteConfig, + orgConfig, + error, + }; } /** diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.css b/tools/apps/publish-requests-inbox/publish-requests-inbox.css index 8d8816b..40cbfa6 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.css +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.css @@ -104,6 +104,11 @@ sl-button.pw-quiet-secondary::part(base):hover:not(:disabled) { } .site-select-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--spacing-200); margin: 0 0 var(--spacing-400) 0; padding: 0; border: none; @@ -827,6 +832,11 @@ sl-button.pw-quiet-secondary::part(base):hover:not(:disabled) { color: var(--s2-red-900); } +.config-status-icon--neutral { + background: var(--s2-gray-200); + color: var(--s2-gray-700); +} + .config-status-label { flex: 1; min-width: 0; @@ -837,6 +847,135 @@ sl-button.pw-quiet-secondary::part(base):hover:not(:disabled) { color: var(--s2-gray-700); } +/* ========== Toolbar Setup pill + panel ========== */ +.setup-pill { + display: inline-flex; + align-items: center; + gap: var(--spacing-100); + padding: 4px var(--spacing-200); + background: var(--s2-gray-100); + color: var(--s2-gray-800); + border: 1px solid var(--s2-gray-300); + border-radius: 999px; + font: inherit; + font-size: var(--s2-body-xs-size); + font-weight: 600; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} + +.setup-pill:hover { + background: var(--s2-gray-200); + border-color: var(--s2-gray-400); +} + +.setup-pill:focus-visible { + outline: 2px solid var(--pw-accent); + outline-offset: 2px; +} + +.setup-pill-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--s2-gray-400); + flex-shrink: 0; +} + +.setup-pill-dot--ok { + background: var(--s2-green-700, #2d9d57); +} + +.setup-pill-dot--attention { + background: var(--s2-orange-700, #d97706); +} + +.setup-pill-chevron { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.setup-panel { + margin-top: var(--spacing-300); + padding: var(--spacing-300) var(--spacing-400); + background: var(--s2-gray-50, #fafafa); + border: 1px solid var(--s2-gray-200); + border-radius: 8px; +} + +.setup-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-200); + margin-bottom: var(--spacing-200); +} + +.setup-panel-title { + margin: 0; + font-size: var(--s2-body-m-size); + font-weight: 700; + color: var(--s2-gray-900); +} + +.setup-panel-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + color: var(--s2-gray-700); + cursor: pointer; +} + +.setup-panel-close:hover { + background: var(--s2-gray-200); + color: var(--s2-gray-900); +} + +.setup-panel-close svg { + width: 16px; + height: 16px; +} + +.setup-panel-error { + margin: 0 0 var(--spacing-200) 0; + font-size: var(--s2-body-s-size); + color: var(--s2-red-900); +} + +.setup-panel-footer { + margin: var(--spacing-200) 0 0 0; + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); +} + +.setup-panel-recheck { + margin-left: var(--spacing-200); + padding: 2px var(--spacing-200); + background: transparent; + border: 1px solid var(--s2-gray-400); + border-radius: 4px; + color: var(--s2-gray-800); + font: inherit; + font-size: var(--s2-body-xs-size); + cursor: pointer; +} + +.setup-panel-recheck:hover:not(:disabled) { + background: var(--s2-gray-200); +} + +.setup-panel-recheck:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .register-form { display: flex; flex-direction: column; diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index 5e1f0ce..71334d5 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -18,7 +18,7 @@ import { resendPublishRequest, fetchSiteConfig, getLiveHostFromConfig, - fetchAccentSettings, + extractAccentSettings, checkSiteExists, checkSiteRegistration, checkDaConfiguration, @@ -112,6 +112,7 @@ class PublishRequestsApp extends LitElement { // DA configuration check (Step 2 of onboarding) _daConfig: { state: true }, _daConfigRechecking: { state: true }, + _setupPanelOpen: { state: true }, }; constructor() { @@ -141,6 +142,7 @@ class PublishRequestsApp extends LitElement { this._availableProviders = []; this._daConfig = null; this._daConfigRechecking = false; + this._setupPanelOpen = false; } connectedCallback() { @@ -210,10 +212,10 @@ class PublishRequestsApp extends LitElement { * not to the approver inbox we're loading here. */ async checkDaConfig(org, site) { - const status = await checkDaConfiguration(org, site, this.token); - this._daConfig = status; + const result = await checkDaConfiguration(org, site, this.token); + this._daConfig = result; - if (status.workflowConfig === 'missing') { + if (result.status.workflowConfig.state === 'missing') { this._state = 'config-missing'; return false; } @@ -221,18 +223,27 @@ class PublishRequestsApp extends LitElement { return true; } + /** + * Load post-onboarding site settings: live host (from admin.hlx.page) and + * the customer's accent color overrides. + * + * Reuses the DA configs already fetched by `checkDaConfig` (cached on + * `_daConfig`) to derive accent settings without a second round trip to + * `admin.da.live`. For paths that don't go through `checkDaConfig` first + * we'd fall back to fetching, but in practice all callsites do. + */ async loadSiteSettings(org, site) { try { - const [siteConfig, accentSettings] = await Promise.all([ - fetchSiteConfig(org, site), - fetchAccentSettings(org, site, this.token), - ]); - this._liveHost = getLiveHostFromConfig(org, site, siteConfig); - if (accentSettings.accentColor) { - this.style.setProperty('--pw-accent', accentSettings.accentColor); + const hlxConfig = await fetchSiteConfig(org, site); + this._liveHost = getLiveHostFromConfig(org, site, hlxConfig); + + const { siteConfig, orgConfig } = this._daConfig || {}; + const accent = extractAccentSettings(siteConfig, orgConfig); + if (accent.accentColor) { + this.style.setProperty('--pw-accent', accent.accentColor); } - if (accentSettings.accentColorHover) { - this.style.setProperty('--pw-accent-hover', accentSettings.accentColorHover); + if (accent.accentColorHover) { + this.style.setProperty('--pw-accent-hover', accent.accentColorHover); } } catch { this._liveHost = null; @@ -414,6 +425,10 @@ class PublishRequestsApp extends LitElement { async handleSiteSelect(e) { if (e) e.preventDefault(); this._message = null; + // Close the Setup panel and drop the previous site's DA config snapshot + // so the pill/panel don't briefly display stale state for the new site. + this._setupPanelOpen = false; + this._daConfig = null; const input = this.shadowRoot.querySelector('#org-site'); const orgSite = (input?.value ?? '').trim(); @@ -922,6 +937,7 @@ class PublishRequestsApp extends LitElement {

    ${title}

    + ${this.renderSetupPill()}
    @@ -943,6 +959,8 @@ class PublishRequestsApp extends LitElement { ${this._siteSelectLoading ? 'Loading...' : primaryLabel}
    + + ${this.renderSetupPanel()}
    `; } @@ -1123,16 +1141,23 @@ class PublishRequestsApp extends LitElement { // ======== Config missing (DA setup) render ======== /** - * Re-run the DA configuration check without reloading the page. If the - * required tab is now in place, fall through to the normal post-check - * flow (load settings → inbox or my-requests). + * Re-run the DA configuration check without reloading the page. + * + * Two contexts call this: + * - From the `config-missing` blocking page: if the required tab is now + * in place, fall through to the normal post-check flow (load settings + * → inbox or my-requests). + * - From the toolbar Setup panel: the user is already in the inbox/ + * my-requests view. Just re-fetch and let the panel re-render — no + * need to reload settings or re-fetch pending requests. */ async handleRecheckConfig() { if (this._daConfigRechecking) return; + const cameFromBlockingPage = this._state === 'config-missing'; this._daConfigRechecking = true; try { const ok = await this.checkDaConfig(this._org, this._site); - if (!ok) return; + if (!ok || !cameFromBlockingPage) return; this._state = 'loading'; await this.loadSiteSettings(this._org, this._site); @@ -1146,25 +1171,76 @@ class PublishRequestsApp extends LitElement { } } + /** + * Map a per-tab status object to the visual treatment used in the row. + * Centralizing the (state → icon variant + text + tone) mapping keeps the + * blocking config-missing page and the toolbar Setup panel in sync. + * + * @param {{state: string, source: 'site'|'org'|null}} status + * @returns {{variant: 'ok'|'missing'|'neutral', label: string}} + */ + // eslint-disable-next-line class-methods-use-this + describeStatus(status) { + let sourceSuffix = ''; + if (status?.source === 'site') sourceSuffix = ' (site-level)'; + else if (status?.source === 'org') sourceSuffix = ' (org-level)'; + switch (status?.state) { + case 'ok': + case 'configured': + return { variant: 'ok', label: `Configured${sourceSuffix}` }; + case 'default': + return { variant: 'neutral', label: 'Using documented defaults' }; + case 'not-needed': + return { variant: 'neutral', label: 'Not needed for this site' }; + case 'empty': + return { variant: 'missing', label: `Tab present but empty${sourceSuffix}` }; + case 'missing': + default: + return { variant: 'missing', label: 'Not configured' }; + } + } + renderConfigStatusRow(label, status) { - const ok = status === 'ok'; - const iconClass = ok ? 'config-status-icon--ok' : 'config-status-icon--missing'; - const text = ok ? 'Configured' : 'Not configured'; - const icon = ok - ? html`` - : html``; + const { variant, label: statusLabel } = this.describeStatus(status); + const iconClass = `config-status-icon--${variant}`; + let icon; + if (variant === 'ok') { + icon = html``; + } else if (variant === 'missing') { + icon = html``; + } else { + icon = html``; + } return html`
  • ${label} - ${text} + ${statusLabel}
  • `; } + /** + * Render the full 5-tab setup status list. Shared between the + * `config-missing` blocking page and the toolbar Setup panel so the two + * surfaces never drift. + */ + renderSetupRows() { + const status = this._daConfig?.status; + if (!status) return nothing; + return html` +
      + ${this.renderConfigStatusRow('publish-workflow-config (required)', status.workflowConfig)} + ${this.renderConfigStatusRow('library — Request Publish plugin', status.library)} + ${this.renderConfigStatusRow('apps — Publish Requests Inbox', status.apps)} + ${this.renderConfigStatusRow('publish-workflow-settings (optional)', status.workflowSettings)} + ${this.renderConfigStatusRow('publish-workflow-groups-to-email (optional)', status.groupsToEmail)} +
    + `; + } + renderConfigMissing() { - const status = this._daConfig || { workflowConfig: 'missing', library: 'missing', apps: 'missing' }; - const fetchError = status.error; + const fetchError = this._daConfig?.error; return html`
    @@ -1183,19 +1259,13 @@ class PublishRequestsApp extends LitElement {

    Configuration status

    ${fetchError ? html`

    ${fetchError}

    ` : nothing} -
      - ${this.renderConfigStatusRow('publish-workflow-config tab (required)', status.workflowConfig)} - ${this.renderConfigStatusRow('library tab — Request Publish plugin', status.library)} - ${this.renderConfigStatusRow('apps tab — Publish Requests Inbox', status.apps)} -
    + ${this.renderSetupRows()}

    See the Request Publish setup guide for the exact rows to add to each tab. The publish-workflow-config tab is required — without it - the workflow has no approver rules to route requests through. The - library and apps tabs are needed for - authors to discover the plugin and this app from DA. + the workflow has no approver rules to route requests through.

    status[k]?.state) + .filter((s) => s === 'missing' || s === 'empty'); + return offenders.length > 0 ? 'attention' : 'ok'; + } + + renderSetupPill() { + if (!this._daConfig) return nothing; + const health = this.setupHealth; + const dotClass = `setup-pill-dot setup-pill-dot--${health}`; + return html` + + `; + } + + renderSetupPanel() { + if (!this._setupPanelOpen || !this._daConfig) return nothing; + const fetchError = this._daConfig.error; + return html` +
    +
    +

    DA configuration status

    + +
    + ${fetchError ? html`

    ${fetchError}

    ` : nothing} + ${this.renderSetupRows()} + +
    + `; + } + // ======== Inbox renders ======== renderInbox() { From b372495fb4b6880a03048c7c71cf7a7774196281 Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Mon, 27 Apr 2026 12:34:53 -0400 Subject: [PATCH 13/20] Funnel users to Setup panel when optional DA config is incomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hide site-select input + "View publish requests" button when setupHealth is 'attention', so users finish optional setup (library, apps, publish-workflow-settings, groups-to-email) before entering the inbox. - Auto-expand the Setup panel right after a successful registration if anything needs attention, so the orange dot isn't missed at the one moment we have the user's attention. - Reword the 'not-needed' status to "Not needed — approver rules use individual emails" so users understand why the publish-workflow-groups-to-email tab can be skipped. Made-with: Cursor --- .../publish-requests-inbox.js | 56 ++++++++++++------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index 71334d5..f5f8827 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -896,6 +896,14 @@ class PublishRequestsApp extends LitElement { const daConfigOk = await this.checkDaConfig(this._org, this._site); if (!daConfigOk) return; + // Right after a successful registration is the highest-signal moment + // to nudge the user toward finishing optional setup. If anything needs + // attention, pop the Setup panel open so the orange dot doesn't get + // missed. Subsequent visits don't auto-open — the pill is enough. + if (this.setupHealth === 'attention') { + this._setupPanelOpen = true; + } + await this.loadSiteSettings(this._org, this._site); if (this._requester) { await this.initMyRequests(); @@ -932,6 +940,11 @@ class PublishRequestsApp extends LitElement { const isRequesterMode = !!this._requester; const title = isRequesterMode ? 'My Publish Requests' : 'Publish Request Inbox'; const primaryLabel = isRequesterMode ? 'View my requests' : 'View publish requests'; + // When optional setup is incomplete, hide the site-select row so the user + // is funnelled to the Setup panel instead of being able to dive into the + // (not-yet-fully-functional) inbox. Only hides once we actually know the + // status; `unknown` keeps the toolbar visible to avoid a flicker. + const hideSiteSelect = this.setupHealth === 'attention'; return html`
    @@ -940,25 +953,27 @@ class PublishRequestsApp extends LitElement { ${this.renderSetupPill()} -
    -
    - { if (e.key === 'Enter') this.handleSiteSelect(); }} - > + ${hideSiteSelect ? nothing : html` +
    +
    + { if (e.key === 'Enter') this.handleSiteSelect(); }} + > +
    + this.handleSiteSelect()} + > + ${this._siteSelectLoading ? 'Loading...' : primaryLabel} +
    - this.handleSiteSelect()} - > - ${this._siteSelectLoading ? 'Loading...' : primaryLabel} - -
    + `} ${this.renderSetupPanel()}
    @@ -1191,7 +1206,10 @@ class PublishRequestsApp extends LitElement { case 'default': return { variant: 'neutral', label: 'Using documented defaults' }; case 'not-needed': - return { variant: 'neutral', label: 'Not needed for this site' }; + return { + variant: 'neutral', + label: 'Not needed — approver rules use individual emails', + }; case 'empty': return { variant: 'missing', label: `Tab present but empty${sourceSuffix}` }; case 'missing': From b3d2fb837fc650c5b7fed97d64a0d448de93d1f4 Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Mon, 27 Apr 2026 14:04:21 -0400 Subject: [PATCH 14/20] Unify setup UX: single setup screen for all required tabs Removes the toolbar Setup pill + dropdown panel and consolidates onto the existing center-aligned setup screen. Approvers and requesters now land directly on the inbox when their site is properly configured; when it isn't, they see one consistent screen explaining what's missing. - Promote `library` and `apps` to required alongside `publish-workflow-config`. Without all three, no publish requests can ever reach the inbox, so blocking on them is more honest than rendering "No Pending Requests" indefinitely. - Drop the toolbar Setup pill, dropdown panel, `_setupPanelOpen` state, `setupHealth` getter, and post-registration auto-expand. - Show org/site as a small chip on the setup screen so admins know exactly which site they're configuring. - Update copy to explicitly call out all three required tabs and explain why they're required (no requests can reach the inbox). - Keep `publish-workflow-settings` and `publish-workflow-groups-to-email` as informational rows on the setup screen but they never gate the inbox. Made-with: Cursor --- .../publish-requests-inbox.css | 163 +++------------ .../publish-requests-inbox.js | 191 +++++------------- 2 files changed, 81 insertions(+), 273 deletions(-) diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.css b/tools/apps/publish-requests-inbox/publish-requests-inbox.css index 40cbfa6..1385e6b 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.css +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.css @@ -104,11 +104,6 @@ sl-button.pw-quiet-secondary::part(base):hover:not(:disabled) { } .site-select-header { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: var(--spacing-200); margin: 0 0 var(--spacing-400) 0; padding: 0; border: none; @@ -776,12 +771,39 @@ sl-button.pw-quiet-secondary::part(base):hover:not(:disabled) { } .register-body { - margin: 0 0 var(--spacing-400) 0; + margin: 0 0 var(--spacing-300) 0; font-size: var(--s2-body-s-size); color: var(--s2-gray-700); line-height: 1.5; } +.register-site-chip { + display: inline-flex; + align-items: center; + gap: var(--spacing-100); + margin: 0 0 var(--spacing-200) 0; + padding: 4px var(--spacing-200); + background: var(--s2-gray-100); + border: 1px solid var(--s2-gray-200); + border-radius: 999px; + font-size: var(--s2-body-xs-size); + color: var(--s2-gray-800); +} + +.register-site-chip-label { + font-weight: 600; + color: var(--s2-gray-700); + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 11px; +} + +.register-site-chip code { + font-family: var(--s2-code-family, ui-monospace, "SF Mono", monospace); + font-size: var(--s2-body-s-size); + color: var(--s2-gray-900); +} + .register-form-card { text-align: left; } @@ -847,135 +869,6 @@ sl-button.pw-quiet-secondary::part(base):hover:not(:disabled) { color: var(--s2-gray-700); } -/* ========== Toolbar Setup pill + panel ========== */ -.setup-pill { - display: inline-flex; - align-items: center; - gap: var(--spacing-100); - padding: 4px var(--spacing-200); - background: var(--s2-gray-100); - color: var(--s2-gray-800); - border: 1px solid var(--s2-gray-300); - border-radius: 999px; - font: inherit; - font-size: var(--s2-body-xs-size); - font-weight: 600; - cursor: pointer; - transition: background 120ms ease, border-color 120ms ease; -} - -.setup-pill:hover { - background: var(--s2-gray-200); - border-color: var(--s2-gray-400); -} - -.setup-pill:focus-visible { - outline: 2px solid var(--pw-accent); - outline-offset: 2px; -} - -.setup-pill-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--s2-gray-400); - flex-shrink: 0; -} - -.setup-pill-dot--ok { - background: var(--s2-green-700, #2d9d57); -} - -.setup-pill-dot--attention { - background: var(--s2-orange-700, #d97706); -} - -.setup-pill-chevron { - width: 14px; - height: 14px; - flex-shrink: 0; -} - -.setup-panel { - margin-top: var(--spacing-300); - padding: var(--spacing-300) var(--spacing-400); - background: var(--s2-gray-50, #fafafa); - border: 1px solid var(--s2-gray-200); - border-radius: 8px; -} - -.setup-panel-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-200); - margin-bottom: var(--spacing-200); -} - -.setup-panel-title { - margin: 0; - font-size: var(--s2-body-m-size); - font-weight: 700; - color: var(--s2-gray-900); -} - -.setup-panel-close { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - padding: 0; - background: transparent; - border: none; - border-radius: 4px; - color: var(--s2-gray-700); - cursor: pointer; -} - -.setup-panel-close:hover { - background: var(--s2-gray-200); - color: var(--s2-gray-900); -} - -.setup-panel-close svg { - width: 16px; - height: 16px; -} - -.setup-panel-error { - margin: 0 0 var(--spacing-200) 0; - font-size: var(--s2-body-s-size); - color: var(--s2-red-900); -} - -.setup-panel-footer { - margin: var(--spacing-200) 0 0 0; - font-size: var(--s2-body-s-size); - color: var(--s2-gray-700); -} - -.setup-panel-recheck { - margin-left: var(--spacing-200); - padding: 2px var(--spacing-200); - background: transparent; - border: 1px solid var(--s2-gray-400); - border-radius: 4px; - color: var(--s2-gray-800); - font: inherit; - font-size: var(--s2-body-xs-size); - cursor: pointer; -} - -.setup-panel-recheck:hover:not(:disabled) { - background: var(--s2-gray-200); -} - -.setup-panel-recheck:disabled { - opacity: 0.6; - cursor: not-allowed; -} - .register-form { display: flex; flex-direction: column; diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index f5f8827..faf22f9 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -112,7 +112,6 @@ class PublishRequestsApp extends LitElement { // DA configuration check (Step 2 of onboarding) _daConfig: { state: true }, _daConfigRechecking: { state: true }, - _setupPanelOpen: { state: true }, }; constructor() { @@ -142,7 +141,6 @@ class PublishRequestsApp extends LitElement { this._availableProviders = []; this._daConfig = null; this._daConfigRechecking = false; - this._setupPanelOpen = false; } connectedCallback() { @@ -199,23 +197,30 @@ class PublishRequestsApp extends LitElement { /** * Step 2 of onboarding: verify the DA-side configuration is in place for - * this org/site (publish-workflow-config required, library/apps tabs - * informational). Returns true when the workflow can proceed, false when + * this org/site. Returns true when the workflow can proceed, false when * the caller should stop and let the `'config-missing'` view render. * + * Three tabs gate the inbox because, in practice, all three are needed + * for any publish request to ever reach this inbox: + * - publish-workflow-config: defines approver routing rules + * - library: makes the request-publish plugin available in DA's sidebar + * so authors can submit a request in the first place + * - apps: registers the inbox app so DA can surface it + * + * Without any one of these, the inbox would render "No Pending Requests" + * indefinitely — which is misleading. We block on all three and surface + * the full status (including the optional tabs) on the setup screen. + * * Called after the registration check passes so we never block on DA * config for a site that's also unregistered. - * - * Library/apps tab statuses are tracked in `_daConfig` and surfaced inside - * the `renderConfigMissing` view, but they do not block the inbox view on - * their own. Their absence only matters to the author-side plugin flow, - * not to the approver inbox we're loading here. */ async checkDaConfig(org, site) { const result = await checkDaConfiguration(org, site, this.token); this._daConfig = result; - if (result.status.workflowConfig.state === 'missing') { + const required = ['workflowConfig', 'library', 'apps']; + const blocked = required.some((key) => result.status[key]?.state === 'missing'); + if (blocked) { this._state = 'config-missing'; return false; } @@ -425,9 +430,8 @@ class PublishRequestsApp extends LitElement { async handleSiteSelect(e) { if (e) e.preventDefault(); this._message = null; - // Close the Setup panel and drop the previous site's DA config snapshot - // so the pill/panel don't briefly display stale state for the new site. - this._setupPanelOpen = false; + // Drop the previous site's DA config snapshot so a stale status doesn't + // briefly render while the new site's config is being fetched. this._daConfig = null; const input = this.shadowRoot.querySelector('#org-site'); @@ -896,14 +900,6 @@ class PublishRequestsApp extends LitElement { const daConfigOk = await this.checkDaConfig(this._org, this._site); if (!daConfigOk) return; - // Right after a successful registration is the highest-signal moment - // to nudge the user toward finishing optional setup. If anything needs - // attention, pop the Setup panel open so the orange dot doesn't get - // missed. Subsequent visits don't auto-open — the pill is enough. - if (this.setupHealth === 'attention') { - this._setupPanelOpen = true; - } - await this.loadSiteSettings(this._org, this._site); if (this._requester) { await this.initMyRequests(); @@ -940,42 +936,32 @@ class PublishRequestsApp extends LitElement { const isRequesterMode = !!this._requester; const title = isRequesterMode ? 'My Publish Requests' : 'Publish Request Inbox'; const primaryLabel = isRequesterMode ? 'View my requests' : 'View publish requests'; - // When optional setup is incomplete, hide the site-select row so the user - // is funnelled to the Setup panel instead of being able to dive into the - // (not-yet-fully-functional) inbox. Only hides once we actually know the - // status; `unknown` keeps the toolbar visible to avoid a flicker. - const hideSiteSelect = this.setupHealth === 'attention'; return html`

    ${title}

    - ${this.renderSetupPill()}
    - ${hideSiteSelect ? nothing : html` -
    -
    - { if (e.key === 'Enter') this.handleSiteSelect(); }} - > -
    - this.handleSiteSelect()} - > - ${this._siteSelectLoading ? 'Loading...' : primaryLabel} - +
    +
    + { if (e.key === 'Enter') this.handleSiteSelect(); }} + >
    - `} - - ${this.renderSetupPanel()} + this.handleSiteSelect()} + > + ${this._siteSelectLoading ? 'Loading...' : primaryLabel} + +
    `; } @@ -1156,23 +1142,18 @@ class PublishRequestsApp extends LitElement { // ======== Config missing (DA setup) render ======== /** - * Re-run the DA configuration check without reloading the page. - * - * Two contexts call this: - * - From the `config-missing` blocking page: if the required tab is now - * in place, fall through to the normal post-check flow (load settings - * → inbox or my-requests). - * - From the toolbar Setup panel: the user is already in the inbox/ - * my-requests view. Just re-fetch and let the panel re-render — no - * need to reload settings or re-fetch pending requests. + * Re-run the DA configuration check from the `config-missing` setup + * screen. If everything required is now in place, fall through to the + * normal post-check flow (load settings → inbox or my-requests). Otherwise + * `checkDaConfig` will leave us in `'config-missing'` with a refreshed + * status list. */ async handleRecheckConfig() { if (this._daConfigRechecking) return; - const cameFromBlockingPage = this._state === 'config-missing'; this._daConfigRechecking = true; try { const ok = await this.checkDaConfig(this._org, this._site); - if (!ok || !cameFromBlockingPage) return; + if (!ok) return; this._state = 'loading'; await this.loadSiteSettings(this._org, this._site); @@ -1249,8 +1230,8 @@ class PublishRequestsApp extends LitElement { return html`
      ${this.renderConfigStatusRow('publish-workflow-config (required)', status.workflowConfig)} - ${this.renderConfigStatusRow('library — Request Publish plugin', status.library)} - ${this.renderConfigStatusRow('apps — Publish Requests Inbox', status.apps)} + ${this.renderConfigStatusRow('library — Request Publish plugin (required)', status.library)} + ${this.renderConfigStatusRow('apps — Publish Requests Inbox (required)', status.apps)} ${this.renderConfigStatusRow('publish-workflow-settings (optional)', status.workflowSettings)} ${this.renderConfigStatusRow('publish-workflow-groups-to-email (optional)', status.groupsToEmail)}
    @@ -1268,9 +1249,13 @@ class PublishRequestsApp extends LitElement {

    DA Configuration Required

    - ${this._org}/${this._site} is registered, but the - DA-side setup for the Request Publish workflow isn't complete yet. - Finish the steps below in your DA config sheet, then re-check. + This site is registered, but the DA-side setup for the Request + Publish workflow isn't complete yet. Finish the required steps + below in your DA config sheet, then re-check. +

    +

    + Site + ${this._org}/${this._site}

    @@ -1282,8 +1267,9 @@ class PublishRequestsApp extends LitElement { See the Request Publish setup guide for the exact rows to add to each tab. The - publish-workflow-config tab is required — without it - the workflow has no approver rules to route requests through. + publish-workflow-config, library, and + apps tabs are all required — without them, no publish + requests can reach this inbox.

    status[k]?.state) - .filter((s) => s === 'missing' || s === 'empty'); - return offenders.length > 0 ? 'attention' : 'ok'; - } - - renderSetupPill() { - if (!this._daConfig) return nothing; - const health = this.setupHealth; - const dotClass = `setup-pill-dot setup-pill-dot--${health}`; - return html` - - `; - } - - renderSetupPanel() { - if (!this._setupPanelOpen || !this._daConfig) return nothing; - const fetchError = this._daConfig.error; - return html` -
    -
    -

    DA configuration status

    - -
    - ${fetchError ? html`

    ${fetchError}

    ` : nothing} - ${this.renderSetupRows()} - -
    - `; - } - // ======== Inbox renders ======== renderInbox() { From 301f81af7f43099fc90a2e6506f0743c4830892a Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Mon, 27 Apr 2026 14:17:00 -0400 Subject: [PATCH 15/20] Loosen library/apps detection to substring match The previous `endsWith('/.../request-for-publish.html')` check failed for real-world `path` values that include env refs (`?env=ci`), omit the `.html` extension, or come from forked deployments. As a result, properly-configured sites were being reported as "Not configured" and incorrectly funnelled to the setup screen. Switch both library and apps inspectors to a case-insensitive substring check against a stable directory needle: - library: path must contain `/tools/plugins/request-for-publish` - apps: path must contain `/tools/apps/publish-requests-inbox` This matches all known URL variations while still rejecting library/apps sheets that don't reference the request-publish plugin at all. Made-with: Cursor --- tools/apps/publish-requests-inbox/api.js | 26 +++++++++++------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tools/apps/publish-requests-inbox/api.js b/tools/apps/publish-requests-inbox/api.js index 6b2f744..bb28ae5 100644 --- a/tools/apps/publish-requests-inbox/api.js +++ b/tools/apps/publish-requests-inbox/api.js @@ -936,26 +936,24 @@ export async function checkSiteRegistration(org, site, token) { // object — provenance promotion (site-level vs. org-level) is layered on // top by `inspectAtBothLevels` below. +// Match a row's `path` cell against a substring needle. Substring (rather +// than endsWith / equality) keeps the check stable across env refs +// (`?env=ci`), with-or-without `.html` extensions, and forks that change +// the host portion of the URL — what we really care about is that the row +// points at the request-publish plugin / inbox app, not the exact URL. +function hasRowWithPathContaining(rows, needle) { + if (!Array.isArray(rows)) return false; + return rows.some((row) => (row?.path || row?.Path || '').toLowerCase().includes(needle)); +} + function inspectLibraryAt(config) { const rows = config?.library?.data; - if (!Array.isArray(rows)) return 'missing'; - const needle = '/tools/plugins/request-for-publish/request-for-publish.html'; - const hasRow = rows.some((row) => { - const p = (row?.path || row?.Path || '').toLowerCase(); - return p.endsWith(needle); - }); - return hasRow ? 'ok' : 'missing'; + return hasRowWithPathContaining(rows, '/tools/plugins/request-for-publish') ? 'ok' : 'missing'; } function inspectAppsAt(config) { const rows = config?.apps?.data; - if (!Array.isArray(rows)) return 'missing'; - const needle = '/tools/apps/publish-requests-inbox/publish-requests-inbox'; - const hasRow = rows.some((row) => { - const p = (row?.path || row?.Path || '').toLowerCase(); - return p.endsWith(needle); - }); - return hasRow ? 'ok' : 'missing'; + return hasRowWithPathContaining(rows, '/tools/apps/publish-requests-inbox') ? 'ok' : 'missing'; } function inspectWorkflowConfigAt(config) { From 9d36c6c5702d65ea906e917e48e9a2b36b3ed4a7 Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Mon, 27 Apr 2026 14:31:45 -0400 Subject: [PATCH 16/20] Add DA config editor links to setup screen Inline 'this site' and 'the org' links in the setup-screen footer point straight at the DA config editor for the relevant scope, removing the manual URL construction step for admins doing first-time setup. Both links are exposed because workflow config can legitimately live at either the site or org level (the inbox already does per-tab fallback); making the user pick is honest about that ambiguity rather than guessing. Open in a new tab so the inbox session is preserved for the post-fix re-check. Made-with: Cursor --- .../publish-requests-inbox/publish-requests-inbox.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index faf22f9..f3937dd 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -1240,6 +1240,8 @@ class PublishRequestsApp extends LitElement { renderConfigMissing() { const fetchError = this._daConfig?.error; + const siteConfigUrl = `https://da.live/config/#/${this._org}/${this._site}/`; + const orgConfigUrl = `https://da.live/config/#/${this._org}/`; return html`
    @@ -1266,8 +1268,12 @@ class PublishRequestsApp extends LitElement {

    See the Request Publish setup guide - for the exact rows to add to each tab. The - publish-workflow-config, library, and + for the exact rows to add to each tab, then open the DA config + editor for + this site + or + the org. + The publish-workflow-config, library, and apps tabs are all required — without them, no publish requests can reach this inbox.

    From 8fae0bf841c4d6596d55e4605954ac1f08f752a3 Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Mon, 27 Apr 2026 14:35:06 -0400 Subject: [PATCH 17/20] Restructure setup-screen footer with scoped bullets Splits the run-on footer paragraph into a scannable two-bullet list that explains each scope's effect, and folds the precedence rule into the org-level bullet where it actually matters: - Site only -> applies only to / - Org -> applies to every site under , unless overridden at site level (site-level wins when both define the same tab) The DA config editor links remain inline within each bullet so users can act on the choice they just read about. Required-tabs reminder and docs link stay as a separate trailing paragraph. Made-with: Cursor --- .../publish-requests-inbox.css | 22 ++++++++++++++++ .../publish-requests-inbox.js | 26 ++++++++++++++----- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.css b/tools/apps/publish-requests-inbox/publish-requests-inbox.css index 1385e6b..84e2c5b 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.css +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.css @@ -869,6 +869,28 @@ sl-button.pw-quiet-secondary::part(base):hover:not(:disabled) { color: var(--s2-gray-700); } +.setup-where-list { + margin: 0 0 var(--spacing-300) 0; + padding-left: var(--spacing-400); + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); + line-height: 1.5; +} + +.setup-where-list li + li { + margin-top: var(--spacing-150, 8px); +} + +.setup-where-list strong { + color: var(--s2-gray-900); +} + +.setup-where-list code { + font-family: var(--s2-code-family, ui-monospace, "SF Mono", monospace); + font-size: 0.95em; + color: var(--s2-gray-900); +} + .register-form { display: flex; flex-direction: column; diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index f3937dd..fa4fd1a 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -1265,15 +1265,29 @@ class PublishRequestsApp extends LitElement {

    Configuration status

    ${fetchError ? html`

    ${fetchError}

    ` : nothing} ${this.renderSetupRows()} +

    + You can add the required tabs at either scope: +

    +
      +
    • + This site only — + open the site config editor. + Rows you add here apply only to + ${this._org}/${this._site}. +
    • +
    • + All sites under the org — + open the org config editor. + Rows you add here apply to every site under + ${this._org}, unless a site overrides the same tab + at site level (site-level config takes precedence). +
    • +

    See the Request Publish setup guide - for the exact rows to add to each tab, then open the DA config - editor for - this site - or - the org. - The publish-workflow-config, library, and + for the exact rows each tab needs. The + publish-workflow-config, library, and apps tabs are all required — without them, no publish requests can reach this inbox.

    From 1fb4e02abd45367b450b68b75751d1032a006938 Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Mon, 27 Apr 2026 14:37:49 -0400 Subject: [PATCH 18/20] typo --- tools/apps/publish-requests-inbox/publish-requests-inbox.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index fa4fd1a..416df02 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -1240,8 +1240,8 @@ class PublishRequestsApp extends LitElement { renderConfigMissing() { const fetchError = this._daConfig?.error; - const siteConfigUrl = `https://da.live/config/#/${this._org}/${this._site}/`; - const orgConfigUrl = `https://da.live/config/#/${this._org}/`; + const siteConfigUrl = `https://da.live/config#/${this._org}/${this._site}/`; + const orgConfigUrl = `https://da.live/config#/${this._org}/`; return html`
    From 2bac84d9a0e5dbe28fc96f6b9bf27d4527d4344e Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Mon, 27 Apr 2026 14:51:58 -0400 Subject: [PATCH 19/20] Plugin: route to inbox app when publish-workflow-config is missing When a site is registered but its publish-workflow-config tab is missing, render the same status-page pattern as the unregistered case (with copy adjusted to "Setup Incomplete") and link the user to the Publish Requests Inbox app to finish setup. Hides the form entirely so authors can't attempt to submit a request that would have no approvers. The "no matching rule for this path" error keeps its inline banner since that's a different problem (admin needs to add a path rule, not run initial setup). Made-with: Cursor --- .../request-for-publish.js | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tools/plugins/request-for-publish/request-for-publish.js b/tools/plugins/request-for-publish/request-for-publish.js index 6555bbc..2423124 100644 --- a/tools/plugins/request-for-publish/request-for-publish.js +++ b/tools/plugins/request-for-publish/request-for-publish.js @@ -68,6 +68,7 @@ class RequestForPublishPlugin extends LitElement { _commentsMinLength: { state: true }, _submitPhase: { state: true }, _siteRegistered: { state: true }, + _configMissing: { state: true }, }; constructor() { @@ -88,6 +89,7 @@ class RequestForPublishPlugin extends LitElement { this._commentsMinLength = 10; this._submitPhase = ''; this._siteRegistered = null; + this._configMissing = false; } connectedCallback() { @@ -161,7 +163,17 @@ class RequestForPublishPlugin extends LitElement { this.style.setProperty('--pw-accent-hover', result.accentColorHover); } - // Show error if config is missing or no matching rule found + // If the publish-workflow-config tab is missing entirely, route the user + // to the inbox app to finish setup (mirrors the unregistered flow). + if (result.source === 'error') { + this._configMissing = true; + this._isLoading = false; + return; + } + + // For other errors (e.g. config exists but no matching rule for this path), + // keep the inline banner — that's a different problem the admin can fix + // by adding a rule to the existing publish-workflow-config tab. if (result.error) { this._message = { type: 'error', text: result.error }; this._isLoading = false; @@ -311,6 +323,29 @@ class RequestForPublishPlugin extends LitElement { `; } + renderConfigMissing() { + const { org, repo: site } = this.context; + return html` +
    +
    + +
    +

    Setup Incomplete

    +

    + ${org}/${site} is registered, but its publish workflow + configuration isn't complete yet. A site administrator needs to finish + setup before publish requests can be submitted. +

    +

    + + + Go to Publish Requests Inbox to finish setup + +

    +
    + `; + } + renderLoading() { return html`
    @@ -516,6 +551,10 @@ class RequestForPublishPlugin extends LitElement { return this.renderUnregistered(); } + if (this._configMissing) { + return this.renderConfigMissing(); + } + if (this._withdrawn) { return this.renderWithdrawn(); } From 53b2bc2600bbe3bc9774163119fd3f17c7af03b7 Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Mon, 27 Apr 2026 15:06:26 -0400 Subject: [PATCH 20/20] Restrict library/apps detection + setup copy to site-only scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DA's per-site Library and Apps surfaces only read from the site-level config sheet — they are not inherited from org config. The setup checker was incorrectly falling back to orgConfig for these two tabs, which could mask a missing site-level config and would also misattribute provenance as "org-level" in the status row. - api.js: add inspectAtSiteOnly helper; use it for library + apps so org config is never consulted for them. Workflow tabs continue to use the documented site-first / org-fallback behavior. - publish-requests-inbox.js: rewrite the "where to add these tabs" bullets in the DA Configuration Required screen to make the per-tab scope rules explicit (library + apps must live in site config; only the publish-workflow-* tabs can be inherited from org). Made-with: Cursor --- tools/apps/publish-requests-inbox/api.js | 47 +++++++++++++------ .../publish-requests-inbox.js | 26 ++++++---- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/tools/apps/publish-requests-inbox/api.js b/tools/apps/publish-requests-inbox/api.js index bb28ae5..e150eb7 100644 --- a/tools/apps/publish-requests-inbox/api.js +++ b/tools/apps/publish-requests-inbox/api.js @@ -976,6 +976,20 @@ function inspectAtBothLevels(siteConfig, orgConfig, inspectAt) { return { state: 'missing', source: null }; } +/** + * Site-only variant for tabs that DA does not inherit from org config + * (`library` and `apps`). DA's per-site Library/Apps surfaces only read + * from the site-level config sheet, so org-level rows have no effect for + * these tabs even though the API would happily return them. + * @param {Object|null} siteConfig + * @param {(c: Object|null) => 'ok'|'missing'} inspectAt + * @returns {{state: 'ok'|'missing', source: 'site'|null}} + */ +function inspectAtSiteOnly(siteConfig, inspectAt) { + if (inspectAt(siteConfig) === 'ok') return { state: 'ok', source: 'site' }; + return { state: 'missing', source: null }; +} + /** * Inspect the optional `publish-workflow-settings` tab. Distinguishes: * - 'configured': tab exists with at least one row @@ -1051,22 +1065,24 @@ function inspectGroupsToEmail(siteConfig, orgConfig) { * for the Request Publish workflow. Mirrors the setup steps in * https://docs.da.live/about/early-access/request-publish (Step 2). * - * Reads site-level config first and falls back per-tab to the org-level - * config — matching the documented "site-first, org fallback" behavior at - * the per-tab granularity. The site/org configs themselves are returned - * alongside the status so the inbox app can derive other settings (e.g. - * accent colors via `extractAccentSettings`) without re-fetching. + * Scope rules: + * - `publish-workflow-*` tabs (config / settings / groups-to-email) are + * inherited from org config when not present at site level. Provenance + * is reported as `'site'` or `'org'`. + * - `library` and `apps` are site-only: DA does not inherit them from org + * config, so we never look at orgConfig for these. Provenance is always + * `'site'` (when configured) or `null` (when missing). + * + * The site/org configs themselves are returned alongside the status so the + * inbox app can derive other settings (e.g. accent colors via + * `extractAccentSettings`) without re-fetching. * * Status values per tab: - * - workflowConfig: { state: 'ok'|'missing', source } - * - library: { state: 'ok'|'missing', source } - * - apps: { state: 'ok'|'missing', source } + * - workflowConfig: { state: 'ok'|'missing', source } + * - library: { state: 'ok'|'missing', source } + * - apps: { state: 'ok'|'missing', source } * - workflowSettings: { state: 'configured'|'empty'|'default', source } - * - groupsToEmail: { state: 'ok'|'missing'|'not-needed', source } - * - * Only `workflowConfig.state === 'missing'` is treated as a hard onboarding - * block by the inbox app — the rest are surfaced for transparency in the - * Setup status panel. + * - groupsToEmail: { state: 'ok'|'missing'|'not-needed', source } * * @param {string} org * @param {string} site @@ -1083,8 +1099,9 @@ export async function checkDaConfiguration(org, site, token) { const status = { workflowConfig: inspectAtBothLevels(siteConfig, orgConfig, inspectWorkflowConfigAt), - library: inspectAtBothLevels(siteConfig, orgConfig, inspectLibraryAt), - apps: inspectAtBothLevels(siteConfig, orgConfig, inspectAppsAt), + // library + apps are site-only; DA does not inherit them from org config. + library: inspectAtSiteOnly(siteConfig, inspectLibraryAt), + apps: inspectAtSiteOnly(siteConfig, inspectAppsAt), workflowSettings: inspectWorkflowSettings(siteConfig, orgConfig), groupsToEmail: inspectGroupsToEmail(siteConfig, orgConfig), }; diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index 416df02..4baaefb 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -1266,21 +1266,27 @@ class PublishRequestsApp extends LitElement { ${fetchError ? html`

    ${fetchError}

    ` : nothing} ${this.renderSetupRows()}

    - You can add the required tabs at either scope: + Where to add these tabs:

    • - This site only — - open the site config editor. - Rows you add here apply only to - ${this._org}/${this._site}. + Site config — + open the site config editor + for ${this._org}/${this._site}. + library and apps must + live here — DA doesn't inherit them from org config. Workflow + tabs (publish-workflow-config, + publish-workflow-settings, + publish-workflow-groups-to-email) added here apply + only to this site.
    • - All sites under the org — - open the org config editor. - Rows you add here apply to every site under - ${this._org}, unless a site overrides the same tab - at site level (site-level config takes precedence). + Org config — + open the org config editor + for ${this._org}. Only the workflow tabs are + inherited from org config; rows added here apply to every site + under the org unless a site overrides the same tab. Site-level + workflow config takes precedence over org-level.