From 961b89f9cfd46197e739b8d1d30e6f919e4fd642 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Thu, 9 Apr 2026 17:12:39 -0400 Subject: [PATCH 01/26] msm app for bulk actions --- tools/apps/msm/helpers/action-panel.css | 748 +++++++++++++++++++++ tools/apps/msm/helpers/action-panel.js | 758 ++++++++++++++++++++++ tools/apps/msm/helpers/api.js | 291 +++++++++ tools/apps/msm/helpers/column-browser.css | 286 ++++++++ tools/apps/msm/helpers/column-browser.js | 371 +++++++++++ tools/apps/msm/msm.css | 189 ++++++ tools/apps/msm/msm.html | 21 + tools/apps/msm/msm.js | 273 ++++++++ 8 files changed, 2937 insertions(+) create mode 100644 tools/apps/msm/helpers/action-panel.css create mode 100644 tools/apps/msm/helpers/action-panel.js create mode 100644 tools/apps/msm/helpers/api.js create mode 100644 tools/apps/msm/helpers/column-browser.css create mode 100644 tools/apps/msm/helpers/column-browser.js create mode 100644 tools/apps/msm/msm.css create mode 100644 tools/apps/msm/msm.html create mode 100644 tools/apps/msm/msm.js diff --git a/tools/apps/msm/helpers/action-panel.css b/tools/apps/msm/helpers/action-panel.css new file mode 100644 index 0000000..faaf384 --- /dev/null +++ b/tools/apps/msm/helpers/action-panel.css @@ -0,0 +1,748 @@ +:host { + display: block; +} + +/* ===== Panel container ===== */ + +.panel { + border: 1px solid var(--s2-gray-200); + border-radius: 8px; + background: #fff; + overflow: hidden; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--s2-gray-75, #f8f8f8); + border-bottom: 1px solid var(--s2-gray-200); +} + +.panel-title { + font-size: 14px; + font-weight: 600; + color: var(--s2-gray-800); + margin: 0; +} + +.panel-subtitle { + font-size: 12px; + color: var(--s2-gray-600); +} + +.panel-body { + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* ===== Satellite filter (tag bar) ===== */ + +.satellite-filter { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.satellite-filter-label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500); + margin-right: 4px; +} + +.sat-tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + font-size: 13px; + border-radius: 16px; + cursor: pointer; + user-select: none; + transition: background-color 0.15s, border-color 0.15s; + border: 1px solid var(--s2-gray-300); + background: #fff; + color: var(--s2-gray-700); +} + +.sat-tag:hover { + border-color: var(--s2-gray-500); +} + +.sat-tag.active { + background: var(--s2-blue-100, #e0ecff); + border-color: var(--s2-blue-400, #96b8f6); + color: var(--s2-blue-900); +} + +.sat-tag input[type="checkbox"] { + appearance: none; + width: 12px; + height: 12px; + margin: 0; + border: 1.5px solid currentcolor; + border-radius: 2px; + position: relative; + cursor: pointer; + flex-shrink: 0; +} + +.sat-tag input[type="checkbox"]:checked { + border-width: 6px; + border-color: var(--s2-blue-900); +} + +.sat-tag input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + inset: 0; + top: -2px; + left: -2px; + width: 3px; + height: 6px; + margin: auto; + border: solid #fff; + border-width: 0 1.5px 1.5px 0; + transform: rotate(45deg); +} + +/* ===== Action row (global controls) ===== */ + +.action-row { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.form-row { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.form-row > label { + font-size: 12px; + display: block; + color: var(--s2-gray-600); +} + +/* ===== Picker (dropdown) ===== */ + +.picker-wrapper { + position: relative; +} + +.picker-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + min-width: 160px; + height: 32px; + padding: 0 10px; + background: var(--s2-gray-75, #f8f8f8); + font-family: inherit; + font-size: 14px; + color: var(--s2-gray-800); + border: 1px solid var(--s2-gray-300); + border-radius: 8px; + cursor: pointer; + box-sizing: border-box; + transition: border-color 0.15s, background-color 0.15s; +} + +.picker-trigger:disabled { + background: var(--s2-gray-75); + border-color: var(--s2-gray-200); + color: var(--s2-gray-400); + cursor: default; +} + +.picker-trigger:hover:not(:disabled) { + border-color: var(--s2-gray-500); +} + +.picker-trigger.open { + border-color: var(--s2-blue-900); +} + +.picker-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.picker-chevron { + width: 10px; + height: 10px; + flex-shrink: 0; + color: var(--s2-gray-600); + transition: transform 0.15s; +} + +.picker-trigger.open .picker-chevron { + transform: rotate(180deg); +} + +.picker-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 10; + list-style: none; + margin: 0; + padding: 6px; + background: #fff; + border: 1px solid var(--s2-gray-200); + border-radius: 8px; + box-shadow: 0 4px 16px rgb(0 0 0 / 12%); + min-width: 180px; +} + +.picker-group-header { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500); + padding: 8px 10px 4px; + user-select: none; +} + +.picker-group-header:first-child { + padding-top: 4px; +} + +.picker-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + font-size: 14px; + color: var(--s2-gray-800); + border-radius: 6px; + cursor: pointer; + user-select: none; + transition: background-color 0.1s; +} + +.picker-item:hover { + background: var(--s2-gray-100, #f5f5f5); +} + +.picker-checkmark { + width: 12px; + height: 12px; + flex-shrink: 0; + visibility: hidden; + color: var(--s2-gray-800); +} + +.picker-item.selected { + font-weight: 600; +} + +.picker-item.selected .picker-checkmark { + visibility: visible; +} + +/* ===== Buttons ===== */ + +.btn { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 16px; + font-family: inherit; + font-size: 14px; + font-weight: 500; + border-radius: 8px; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.15s, border-color 0.15s, opacity 0.15s; + border: 1px solid var(--s2-gray-300); + background: #fff; + color: var(--s2-gray-800); +} + +.btn:disabled { + opacity: 0.4; + cursor: default; +} + +.btn:hover:not(:disabled) { + border-color: var(--s2-gray-500); + background: var(--s2-gray-100); +} + +.btn.primary { + background: var(--s2-blue-900, #3b63fb); + border-color: var(--s2-blue-900, #3b63fb); + color: #fff; +} + +.btn.primary:hover:not(:disabled) { + background: var(--s2-blue-1000, #2d4ec2); + border-color: var(--s2-blue-1000, #2d4ec2); +} + +.btn.danger { + color: var(--s2-red-900, #d31510); + border-color: #f0c8c2; +} + +.btn.danger:hover:not(:disabled) { + background: #fef0ee; + border-color: var(--s2-red-900); +} + +/* ===== Satellite list (single-page mode) ===== */ + +.satellite-grid { + display: flex; + gap: 16px; +} + +.satellite-column { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.column-heading { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500); + padding-bottom: 4px; + border-bottom: 1px solid var(--s2-gray-200); + margin-bottom: 2px; +} + +.satellite-list { + list-style: none; + padding: 0; + margin: 0; + max-height: 200px; + overflow-y: auto; +} + +.satellite-list::-webkit-scrollbar { width: 5px; } +.satellite-list::-webkit-scrollbar-track { background: transparent; } + +.satellite-list::-webkit-scrollbar-thumb { + background: var(--s2-gray-300); + border-radius: 3px; +} + +.sat-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 4px; + border-radius: 4px; + transition: background-color 0.1s; +} + +.sat-row:hover:not(.out-of-scope) { + background: var(--s2-gray-100, #f5f5f5); +} + +.sat-row.out-of-scope { + opacity: 0.38; +} + +.sat-row label { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + cursor: pointer; + font-size: 14px; + color: var(--s2-gray-900); +} + +.sat-row label span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* stylelint-disable no-descending-specificity */ +.sat-row input[type="checkbox"] { + appearance: none; + width: 14px; + height: 14px; + margin: 0; + border: 2px solid var(--s2-gray-600); + border-radius: 2px; + cursor: pointer; + flex-shrink: 0; + position: relative; + transition: border 0.13s ease-in-out; +} + +.sat-row input[type="checkbox"]:checked { + border-color: var(--s2-gray-800); + border-width: 7px; + background: var(--s2-gray-50, #f8f8f8); +} + +.sat-row input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + inset: 0; + top: -2px; + left: -3px; + width: 4px; + height: 8px; + margin: auto; + border: solid var(--s2-gray-50, #f8f8f8); + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} +/* stylelint-enable no-descending-specificity */ + +/* ===== Page table (bulk mode) ===== */ + +.page-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +.page-table th { + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500); + padding: 6px 8px; + border-bottom: 1px solid var(--s2-gray-200); +} + +.page-table td { + padding: 8px; + font-size: 14px; + color: var(--s2-gray-800); + border-bottom: 1px solid var(--s2-gray-100); + vertical-align: middle; +} + +.page-table tr:last-child td { + border-bottom: none; +} + +.page-table tr:hover td { + background: var(--s2-gray-50); +} + +.page-name { + font-weight: 500; +} + +.override-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--s2-gray-600); +} + +.override-badge .dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.override-badge .dot.inh { + background: var(--s2-gray-400); +} + +.override-badge .dot.cust { + background: var(--s2-orange-700, #e68619); +} + +.row-actions { + display: flex; + align-items: center; + gap: 8px; +} + +/* ===== Expandable row detail ===== */ + +.expand-row td { /* stylelint-disable-line no-descending-specificity */ + padding: 0 8px 12px 32px; + background: var(--s2-gray-50); +} + +.expand-content { + display: flex; + gap: 16px; + font-size: 13px; +} + +.expand-column { + flex: 1; +} + +.expand-heading { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500); + margin-bottom: 4px; +} + +.expand-list { + list-style: none; + padding: 0; + margin: 0; +} + +.expand-list li { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 0; + color: var(--s2-gray-700); +} + +/* ===== Status icons ===== */ + +.result-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.result-icon.success { color: #0d6e31; } +.result-icon.error { color: var(--s2-red-900, #d31510); } + +.result-icon.pending { + color: var(--s2-gray-600); + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* ===== Confirm dialog ===== */ + +.confirm-box { + padding: 12px; + background: #fef9ee; + border: 1px solid #f0dca0; + border-radius: 8px; + font-size: 14px; + color: var(--s2-gray-900); +} + +.confirm-box p { + margin: 0 0 10px; + line-height: 1.4; +} + +.confirm-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +/* ===== Progress view ===== */ + +.progress-view { + display: flex; + flex-direction: column; + gap: 12px; +} + +.progress-bar-container { + height: 6px; + background: var(--s2-gray-200); + border-radius: 3px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + background: var(--s2-blue-900); + border-radius: 3px; + transition: width 0.3s ease; +} + +.progress-summary { + font-size: 13px; + color: var(--s2-gray-700); + display: flex; + gap: 16px; +} + +.progress-summary .count-success { color: #0d6e31; } +.progress-summary .count-error { color: var(--s2-red-900); } + +.progress-list { + list-style: none; + padding: 0; + margin: 0; + max-height: 300px; + overflow-y: auto; +} + +.progress-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 4px; + font-size: 13px; + color: var(--s2-gray-800); + border-bottom: 1px solid var(--s2-gray-100); +} + +.progress-item:last-child { + border-bottom: none; +} + +.progress-item .page-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.progress-item .sat-label { + font-size: 12px; + color: var(--s2-gray-600); +} + +.progress-item .error-msg { + font-size: 12px; + color: var(--s2-red-900); +} + +/* ===== Form actions (footer) ===== */ + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; +} + +/* ===== Responsive ===== */ + +@media (width <= 600px) { + .action-row { + flex-direction: column; + align-items: stretch; + } + + .satellite-grid { + flex-direction: column; + } + + .expand-content { + flex-direction: column; + } + + .page-table { + font-size: 13px; + } + + .form-actions { + position: sticky; + bottom: 0; + background: #fff; + padding: 12px 0; + border-top: 1px solid var(--s2-gray-200); + } +} + +/* ===== Toggle expand arrow ===== */ + +.expand-toggle { + width: 16px; + height: 16px; + cursor: pointer; + color: var(--s2-gray-500); + transition: transform 0.15s; + flex-shrink: 0; +} + +.expand-toggle.expanded { + transform: rotate(90deg); +} + +/* ===== Loading overlay ===== */ + +.panel-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + color: var(--s2-gray-500); + font-size: 13px; + font-style: italic; +} + +.panel-loading .mini-spinner { + width: 14px; + height: 14px; + margin-right: 8px; + border: 2px solid var(--s2-gray-200); + border-top-color: var(--s2-gray-600); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* ===== Edit link ===== */ + +.edit-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; + border: none; + border-radius: 4px; + background: none; + color: var(--s2-gray-500); + cursor: pointer; + padding: 0; + text-decoration: none; + transition: background-color 0.15s, color 0.15s; +} + +.edit-link:hover { + background: var(--s2-gray-200); + color: var(--s2-gray-900); +} + +.edit-link svg { + width: 14px; + height: 14px; + fill: currentcolor; +} diff --git a/tools/apps/msm/helpers/action-panel.js b/tools/apps/msm/helpers/action-panel.js new file mode 100644 index 0000000..defe4e0 --- /dev/null +++ b/tools/apps/msm/helpers/action-panel.js @@ -0,0 +1,758 @@ +/* eslint-disable no-underscore-dangle, import/no-unresolved, no-console, class-methods-use-this */ +import { LitElement, html, nothing } from 'da-lit'; +import { executeBulkAction } from './api.js'; + +const NX = 'https://da.live/nx'; +let sheet; +try { + const { default: getStyle } = await import(`${NX}/utils/styles.js`); + sheet = await getStyle(import.meta.url); +} catch (e) { + console.warn('Failed to load action-panel styles:', e); +} + +const CHEVRON = html``; +const CHECK_ICON = html``; +const SPINNER_ICON = html``; +const SUCCESS_ICON = html``; +const ERROR_ICON = html``; +const EDIT_ICON = html``; + +const BASE_ACTION_OPTIONS = [ + { + heading: 'Inherited sites', + items: [ + { value: 'preview', label: 'Preview' }, + { value: 'publish', label: 'Publish' }, + { value: 'break', label: 'Cancel inheritance' }, + ], + }, + { + heading: 'Custom sites', + items: [ + { value: 'sync', label: 'Sync to satellite' }, + { value: 'reset', label: 'Resume inheritance' }, + ], + }, +]; + +const SAT_ACTION_OPTIONS = [ + { value: 'preview', label: 'Preview' }, + { value: 'publish', label: 'Publish' }, +]; + +const SYNC_OPTIONS = [ + { value: 'merge', label: 'Merge' }, + { value: 'override', label: 'Override' }, +]; + +const ACTION_SCOPE = { + preview: 'inherited', + publish: 'inherited', + break: 'inherited', + sync: 'custom', + reset: 'custom', +}; + +const ALL_ACTION_ITEMS = [ + ...BASE_ACTION_OPTIONS.flatMap((g) => g.items), + ...SAT_ACTION_OPTIONS, +]; + +function getActionLabel(value) { + return ALL_ACTION_ITEMS.find((i) => i.value === value)?.label || value; +} + +class MsmActionPanel extends LitElement { + static properties = { + org: { type: String }, + role: { type: String }, + site: { type: String }, + pages: { attribute: false }, + satellites: { attribute: false }, + overrides: { attribute: false }, + isSinglePage: { type: Boolean }, + _globalAction: { state: true }, + _globalSyncMode: { state: true }, + _pageActions: { state: true }, + _selectedSats: { state: true }, + _singleSelectedSats: { state: true }, + _openPicker: { state: true }, + _expandedRows: { state: true }, + _confirmAction: { state: true }, + _executing: { state: true }, + _taskStatuses: { state: true }, + _busy: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + if (sheet) this.shadowRoot.adoptedStyleSheets = [sheet]; + this._globalAction = 'preview'; + this._globalSyncMode = 'merge'; + this._pageActions = new Map(); + this._selectedSats = new Set(Object.keys(this.satellites || {})); + this._singleSelectedSats = new Set(Object.keys(this.satellites || {})); + this._openPicker = null; + this._expandedRows = new Set(); + this._confirmAction = null; + this._executing = false; + this._taskStatuses = new Map(); + this._busy = false; + this._handleOutsideClick = this._handleOutsideClick.bind(this); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('pointerdown', this._handleOutsideClick); + } + + updated(changed) { + if (changed.has('satellites') && this.satellites) { + this._selectedSats = new Set(Object.keys(this.satellites)); + this._singleSelectedSats = new Set(Object.keys(this.satellites)); + } + if (changed.has('pages') || changed.has('isSinglePage')) { + this._executing = false; + this._taskStatuses = new Map(); + this._pageActions = new Map(); + this._expandedRows = new Set(); + } + } + + _handleOutsideClick(e) { + if (!e.composedPath().includes(this)) { + this._openPicker = null; + document.removeEventListener('pointerdown', this._handleOutsideClick); + } + } + + togglePicker(name) { + if (this._openPicker === name) { + this._openPicker = null; + document.removeEventListener('pointerdown', this._handleOutsideClick); + } else { + this._openPicker = name; + document.addEventListener('pointerdown', this._handleOutsideClick); + } + } + + selectPickerOption(name, value, setter) { + setter(value); + this._openPicker = null; + document.removeEventListener('pointerdown', this._handleOutsideClick); + } + + get _isSatellite() { + return this.role === 'satellite'; + } + + get _actionOptions() { + return this._isSatellite ? SAT_ACTION_OPTIONS : BASE_ACTION_OPTIONS; + } + + // ── Satellite filter ── + + toggleSatFilter(satSite) { + const next = new Set(this._selectedSats); + if (next.has(satSite)) next.delete(satSite); + else next.add(satSite); + this._selectedSats = next; + } + + toggleSingleSat(satSite) { + const next = new Set(this._singleSelectedSats); + if (next.has(satSite)) next.delete(satSite); + else next.add(satSite); + this._singleSelectedSats = next; + } + + // ── Per-page action ── + + getPageAction(pagePath) { + return this._pageActions.get(pagePath) || this._globalAction; + } + + setPageAction(pagePath, value) { + const next = new Map(this._pageActions); + if (value === this._globalAction) { + next.delete(pagePath); + } else { + next.set(pagePath, value); + } + this._pageActions = next; + } + + // ── Expand/collapse rows ── + + toggleRow(pagePath) { + const next = new Set(this._expandedRows); + if (next.has(pagePath)) next.delete(pagePath); + else next.add(pagePath); + this._expandedRows = next; + } + + // ── Override helpers ── + + getPageOverrides(pagePath) { + return this.overrides?.get(pagePath) || []; + } + + getOverrideSummary(pagePath) { + const ov = this.getPageOverrides(pagePath); + if (!ov.length) return { inherited: 0, custom: 0 }; + const custom = ov.filter((o) => o.hasOverride).length; + return { inherited: ov.length - custom, custom }; + } + + getFilteredSatellites() { + return Object.entries(this.satellites || {}).filter(([satSite]) => ( + this._selectedSats.has(satSite) + )).reduce((acc, [s, info]) => { acc[s] = info; return acc; }, {}); + } + + // ── Execution ── + + async executeAll() { + if (this._busy) return; + + const summary = this.buildExecutionSummary(); + this._confirmAction = { + message: summary, + onConfirm: () => this.doExecuteAll(), + }; + } + + buildExecutionSummary() { + const counts = this.pages.reduce((acc, page) => { + const action = this.getPageAction(page.path); + acc[action] = (acc[action] || 0) + 1; + return acc; + }, {}); + const parts = Object.entries(counts).map( + ([a, c]) => `${getActionLabel(a)} ${c} page${c > 1 ? 's' : ''}`, + ); + const satCount = this._selectedSats.size; + return `${parts.join(', ')} across ${satCount} satellite${satCount !== 1 ? 's' : ''}. Continue?`; + } + + cancelConfirm() { + this._confirmAction = null; + } + + async doExecuteAll() { + this._confirmAction = null; + this._executing = true; + this._busy = true; + this._taskStatuses = new Map(); + + const actionGroups = this.pages.reduce((acc, page) => { + const action = this.getPageAction(page.path); + if (!acc.has(action)) acc.set(action, []); + acc.get(action).push(page); + return acc; + }, new Map()); + + const statusCallback = (key, status, error) => { + const next = new Map(this._taskStatuses); + next.set(key, { status, error }); + this._taskStatuses = next; + }; + + const groupEntries = [...actionGroups.entries()] + .filter(([action]) => { + const filteredSats = this.getFilteredSatellites(action); + return Object.keys(filteredSats).length > 0; + }); + + await groupEntries.reduce((chain, [action, groupPages]) => chain.then(() => { + const filteredSats = this.getFilteredSatellites(action); + return executeBulkAction({ + org: this.org, + baseSite: this.site, + pages: groupPages, + satellites: filteredSats, + action, + syncMode: action === 'sync' ? this._globalSyncMode : undefined, + onPageStatus: statusCallback, + }); + }), Promise.resolve()); + + this._busy = false; + } + + async executeSinglePage() { + if (this._busy) return; + + const page = this.pages[0]; + const action = this._globalAction; + + if (action === 'reset') { + this._confirmAction = { + message: 'Resume inheritance? This deletes local overrides for selected satellites.', + onConfirm: () => this.doExecuteSingle(page, action), + }; + return; + } + + this.doExecuteSingle(page, action); + } + + async doExecuteSingle(page, action) { + this._confirmAction = null; + this._executing = true; + this._busy = true; + this._taskStatuses = new Map(); + + const sats = Object.entries(this.satellites || {}) + .filter(([s]) => this._singleSelectedSats.has(s)) + .reduce((acc, [s, info]) => { acc[s] = info; return acc; }, {}); + + await executeBulkAction({ + org: this.org, + baseSite: this.site, + pages: [page], + satellites: sats, + action, + syncMode: action === 'sync' || action === 'sync-from-base' + ? this._globalSyncMode : undefined, + onPageStatus: (key, status, error) => { + const next = new Map(this._taskStatuses); + next.set(key, { status, error }); + this._taskStatuses = next; + }, + }); + + this._busy = false; + } + + async executeRow(page) { + if (this._busy) return; + + const action = this.getPageAction(page.path); + if (action === 'reset') { + this._confirmAction = { + message: `Resume inheritance for ${page.name}? This deletes local overrides.`, + onConfirm: () => this.doExecuteSingle(page, action), + }; + return; + } + this.doExecuteSingle(page, action); + } + + // ── Status icon helper ── + + statusIcon(status) { + if (status === 'pending') return SPINNER_ICON; + if (status === 'success') return SUCCESS_ICON; + if (status === 'error') return ERROR_ICON; + return nothing; + } + + // ── Progress stats ── + + get _progressStats() { + return [...this._taskStatuses.values()].reduce((acc, { status }) => { + acc.total += 1; + if (status === 'success') { acc.done += 1; acc.success += 1; } + if (status === 'error') { acc.done += 1; acc.error += 1; } + return acc; + }, { + total: 0, done: 0, success: 0, error: 0, + }); + } + + // ────────────────────────────────────── + // Render: Picker (reusable) + // ────────────────────────────────────── + + renderPicker(name, label, value, options, setter) { + const isOpen = this._openPicker === name; + const selectedLabel = options + .flatMap((o) => o.items || [o]) + .find((o) => o.value === value)?.label || ''; + + return html` +
+ +
+ + ${isOpen ? html` +
    + ${options.map((group) => { + if (group.items) { + return html` +
  • ${group.heading}
  • + ${group.items.map((opt) => html` +
  • this.selectPickerOption(name, opt.value, setter)}> + ${CHECK_ICON} + ${opt.label} +
  • + `)} + `; + } + return html` +
  • this.selectPickerOption(name, group.value, setter)}> + ${CHECK_ICON} + ${group.label} +
  • + `; + })} +
+ ` : nothing} +
+
+ `; + } + + // ────────────────────────────────────── + // Render: Confirm dialog + // ────────────────────────────────────── + + renderConfirm() { + if (!this._confirmAction) return nothing; + return html` +
+

${this._confirmAction.message}

+
+ + +
+
+ `; + } + + // ────────────────────────────────────── + // Render: Single-page mode + // ────────────────────────────────────── + + renderSinglePage() { + const page = this.pages[0]; + const overrides = this.getPageOverrides(page.path); + const inherited = overrides.filter((o) => !o.hasOverride); + const custom = overrides.filter((o) => o.hasOverride); + + return html` +
+
+

${this._isSatellite ? '' : 'MSM: '}${page.name}

+
+
+
+ ${this.renderPicker( + 'action', + 'Action', + this._globalAction, + this._actionOptions, + (v) => { this._globalAction = v; }, + )} + ${this._globalAction === 'sync' ? this.renderPicker( + 'syncMode', + 'Sync mode', + this._globalSyncMode, + SYNC_OPTIONS, + (v) => { this._globalSyncMode = v; }, + ) : nothing} +
+ + ${this._isSatellite ? nothing : this.renderSatelliteGrid(inherited, custom)} + ${this.renderConfirm()} + ${this._executing ? this.renderProgress() : nothing} + +
+ +
+
+
+ `; + } + + renderSatelliteGrid(inherited, custom) { + const scope = ACTION_SCOPE[this._globalAction]; + + return html` +
+ ${inherited.length ? html` +
+
Inherited
+
    + ${inherited.map((sat) => this.renderSatRow(sat, scope !== 'inherited'))} +
+
+ ` : nothing} + ${custom.length ? html` +
+
Custom
+
    + ${custom.map((sat) => this.renderSatRow(sat, scope !== 'custom', true))} +
+
+ ` : nothing} +
+ `; + } + + renderSatRow(sat, outOfScope, showEdit = false) { + const statusEntry = this._taskStatuses.get(`${this.pages[0]?.path}:${sat.site}`); + return html` +
  • + + ${statusEntry ? this.statusIcon(statusEntry.status) : nothing} + ${showEdit ? html` + + ${EDIT_ICON} + + ` : nothing} +
  • + `; + } + + // ────────────────────────────────────── + // Render: Bulk mode + // ────────────────────────────────────── + + renderBulk() { + return html` +
    +
    +

    ${this.pages.length} pages selected

    + ${this.site} +
    +
    + ${this._isSatellite ? nothing : this.renderSatelliteFilter()} + ${this.renderGlobalActionBar()} + ${this.renderConfirm()} + ${this._executing ? this.renderProgress() : this.renderPageTable()} +
    + +
    +
    +
    + `; + } + + renderSatelliteFilter() { + const sats = Object.entries(this.satellites || {}); + if (sats.length <= 1) return nothing; + + return html` +
    + Satellites + ${sats.map(([satSite, info]) => html` + + `)} +
    + `; + } + + renderGlobalActionBar() { + return html` +
    + ${this.renderPicker( + 'globalAction', + 'Action for all', + this._globalAction, + this._actionOptions, + (v) => { this._globalAction = v; this._pageActions = new Map(); }, + )} + ${this._globalAction === 'sync' ? this.renderPicker( + 'globalSyncMode', + 'Sync mode', + this._globalSyncMode, + SYNC_OPTIONS, + (v) => { this._globalSyncMode = v; }, + ) : nothing} +
    + `; + } + + renderPageTable() { + return html` + + + + ${this._isSatellite ? nothing : html``} + + ${this._isSatellite ? nothing : html``} + + + + + + ${this.pages.map((page) => this.renderPageRow(page))} + +
    PageOverridesAction
    + `; + } + + renderPageRow(page) { + const isExpanded = this._expandedRows.has(page.path); + const summary = this.getOverrideSummary(page.path); + const action = this.getPageAction(page.path); + const hasCustomAction = this._pageActions.has(page.path); + + return html` + + ${this._isSatellite ? nothing : html` + + this.toggleRow(page.path)}> + + + + `} + ${page.name} + ${this._isSatellite ? nothing : html` + + + ${summary.inherited} inh + + ${summary.custom > 0 ? html` + + ${summary.custom} cust + + ` : nothing} + + `} + + ${this.renderPicker( + `page-${page.path}`, + '', + action, + this._actionOptions, + (v) => this.setPageAction(page.path, v), + )} + ${hasCustomAction ? html`custom` : nothing} + + +
    + +
    + + + ${isExpanded ? this.renderExpandedRow(page) : nothing} + `; + } + + renderExpandedRow(page) { + const overrides = this.getPageOverrides(page.path); + const inherited = overrides.filter((o) => !o.hasOverride); + const custom = overrides.filter((o) => o.hasOverride); + + return html` + + +
    + ${inherited.length ? html` +
    +
    Inherited
    +
      + ${inherited.map((sat) => html` +
    • + ${this.statusIcon(this._taskStatuses.get(`${page.path}:${sat.site}`)?.status)} + ${sat.label} +
    • + `)} +
    +
    + ` : nothing} + ${custom.length ? html` +
    +
    Custom
    +
      + ${custom.map((sat) => html` +
    • + ${this.statusIcon(this._taskStatuses.get(`${page.path}:${sat.site}`)?.status)} + ${sat.label} + + ${EDIT_ICON} + +
    • + `)} +
    +
    + ` : nothing} +
    + + + `; + } + + // ────────────────────────────────────── + // Render: Progress view + // ────────────────────────────────────── + + renderProgress() { + const stats = this._progressStats; + const pct = stats.total ? Math.round((stats.done / stats.total) * 100) : 0; + + return html` +
    +
    +
    +
    +
    + ${stats.done} / ${stats.total} complete + ${stats.success > 0 ? html`${stats.success} succeeded` : nothing} + ${stats.error > 0 ? html`${stats.error} failed` : nothing} +
    + +
    + `; + } + + // ────────────────────────────────────── + // Main render + // ────────────────────────────────────── + + render() { + if (!this.pages?.length) return nothing; + + if (this.isSinglePage) return this.renderSinglePage(); + return this.renderBulk(); + } +} + +customElements.define('msm-action-panel', MsmActionPanel); diff --git a/tools/apps/msm/helpers/api.js b/tools/apps/msm/helpers/api.js new file mode 100644 index 0000000..5d6f560 --- /dev/null +++ b/tools/apps/msm/helpers/api.js @@ -0,0 +1,291 @@ +/* eslint-disable no-underscore-dangle, import/no-unresolved, no-console */ + +const DA_ORIGIN = 'https://admin.da.live'; +const AEM_ADMIN = 'https://admin.hlx.page'; +const MAX_CONCURRENT = 5; + +let daFetchFn; +let fetchDaConfigsFn; + +async function ensureDeps() { + if (!daFetchFn) { + const utils = await import('https://da.live/nx/public/utils/daFetch.js'); + daFetchFn = utils.daFetch; + } + if (!fetchDaConfigsFn) { + const utils = await import('https://da.live/nx/public/utils/utils.js'); + fetchDaConfigsFn = utils.fetchDaConfigs; + } +} + +async function daFetch(url, opts = {}) { + await ensureDeps(); + return daFetchFn(url, opts); +} + +// ────────────────────────────────────────────── +// Concurrency limiter for bulk operations +// ────────────────────────────────────────────── + +/* eslint-disable no-restricted-syntax, no-await-in-loop */ +async function runWithConcurrency(tasks, limit = MAX_CONCURRENT) { + const results = []; + const executing = new Set(); + + for (const task of tasks) { + const p = task().then((r) => { executing.delete(p); return r; }); + executing.add(p); + results.push(p); + if (executing.size >= limit) { + await Promise.race(executing); + } + } + + return Promise.allSettled(results); +} +/* eslint-enable no-restricted-syntax, no-await-in-loop */ + +// ────────────────────────────────────────────── +// MSM Config +// ────────────────────────────────────────────── + +const configCache = {}; + +async function fetchOrgMsmRows(org) { + await ensureDeps(); + const configs = await fetchDaConfigsFn({ org }); + const orgConfig = configs?.[0]; + return orgConfig?.msm?.data || []; +} + +function resolveBaseSites(rows) { + const hasBaseCol = rows.length > 0 && rows[0].base !== undefined; + if (!hasBaseCol) return []; + + const baseSites = rows + .filter((row) => row.base) + .reduce((acc, row) => { + if (!acc.has(row.base)) { + acc.set(row.base, { site: row.base, label: '', satellites: {} }); + } + const entry = acc.get(row.base); + if (!row.satellite) { + entry.label = row.title || row.base; + } else { + entry.satellites[row.satellite] = { label: row.title || row.satellite }; + } + return acc; + }, new Map()); + + return [...baseSites.values()].filter((b) => Object.keys(b.satellites).length > 0); +} + +export async function fetchMsmConfig(org) { + if (configCache[org]) return configCache[org]; + + const rows = await fetchOrgMsmRows(org); + if (!rows.length) return null; + + const baseSites = resolveBaseSites(rows); + if (!baseSites.length) return null; + + const config = { baseSites }; + configCache[org] = config; + return config; +} + +// ────────────────────────────────────────────── +// Folder listing via DA Admin +// ────────────────────────────────────────────── + +export async function listFolder(org, site, path = '/') { + const cleanPath = path.startsWith('/') ? path : `/${path}`; + const url = `${DA_ORIGIN}/list/${org}/${site}${cleanPath}`; + const resp = await daFetch(url); + if (!resp.ok) return []; + const items = await resp.json(); + return items.map((item) => ({ + name: item.name, + path: item.path || `${cleanPath === '/' ? '' : cleanPath}/${item.name}`, + ext: item.ext || (item.name.includes('.') ? item.name.split('.').pop() : null), + isFolder: !item.ext && !item.name.includes('.'), + })); +} + +// ────────────────────────────────────────────── +// Override checking +// ────────────────────────────────────────────── + +export async function checkPageOverrides(org, satellites, pagePath) { + const entries = Object.entries(satellites); + const results = await Promise.all( + entries.map(async ([site, info]) => { + const url = `${DA_ORIGIN}/source/${org}/${site}${pagePath}.html`; + const resp = await daFetch(url, { method: 'HEAD' }); + return { site, label: info.label, hasOverride: resp.ok }; + }), + ); + return results; +} + +// ────────────────────────────────────────────── +// Preview / Publish +// ────────────────────────────────────────────── + +export async function previewSatellite(org, satellite, pagePath) { + const aemPath = pagePath.replace(/\.html$/, ''); + const url = `${AEM_ADMIN}/preview/${org}/${satellite}/main${aemPath}`; + const resp = await daFetch(url, { method: 'POST' }); + if (!resp.ok) { + const xError = resp.headers?.get('x-error') || `Preview failed (${resp.status})`; + return { error: xError }; + } + return resp.json(); +} + +export async function publishSatellite(org, satellite, pagePath) { + const aemPath = pagePath.replace(/\.html$/, ''); + const url = `${AEM_ADMIN}/live/${org}/${satellite}/main${aemPath}`; + const resp = await daFetch(url, { method: 'POST' }); + if (!resp.ok) { + const xError = resp.headers?.get('x-error') || `Publish failed (${resp.status})`; + return { error: xError }; + } + return resp.json(); +} + +// ────────────────────────────────────────────── +// Override management +// ────────────────────────────────────────────── + +export async function createOverride(org, baseSite, satellite, pagePath) { + const basePath = `${DA_ORIGIN}/source/${org}/${baseSite}${pagePath}.html`; + const resp = await daFetch(basePath); + if (!resp.ok) return { error: `Failed to fetch base content (${resp.status})` }; + + const content = await resp.text(); + const blob = new Blob([content], { type: 'text/html' }); + const formData = new FormData(); + formData.append('data', blob); + + const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.html`; + const saveResp = await daFetch(satPath, { method: 'PUT', body: formData }); + if (!saveResp.ok) return { error: `Failed to create override (${saveResp.status})` }; + return { ok: true }; +} + +export async function deleteOverride(org, satellite, pagePath) { + const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.html`; + const resp = await daFetch(satPath, { method: 'DELETE' }); + if (!resp.ok) return { error: `Failed to delete override (${resp.status})` }; + return { ok: true }; +} + +export async function getSatellitePageStatus(org, satellite, pagePath) { + const aemPath = pagePath.replace(/\.html$/, ''); + const url = `${AEM_ADMIN}/status/${org}/${satellite}/main${aemPath}`; + const resp = await daFetch(url); + if (!resp.ok) return { preview: false, live: false }; + const json = await resp.json(); + return { + preview: json.preview?.status === 200, + live: json.live?.status === 200, + }; +} + +// ────────────────────────────────────────────── +// Merge from base (uses NX mergeCopy) +// ────────────────────────────────────────────── + +let mergeCopyFn; + +async function ensureMergeCopy() { + if (!mergeCopyFn) { + const mod = await import('https://da.live/nx/blocks/loc/project/index.js'); + mergeCopyFn = mod.mergeCopy; + } + return mergeCopyFn; +} + +export async function mergeFromBase(org, baseSite, satellite, pagePath) { + try { + const mergeCopy = await ensureMergeCopy(); + const url = { + source: `/${org}/${baseSite}${pagePath}.html`, + destination: `/${org}/${satellite}${pagePath}.html`, + }; + const result = await mergeCopy(url, 'MSM Merge'); + if (!result?.ok) return { error: 'Merge failed' }; + return { ok: true }; + } catch (e) { + return { error: e.message || 'Merge failed' }; + } +} + +// ────────────────────────────────────────────── +// Bulk action executors +// ────────────────────────────────────────────── + +export async function executeBulkAction({ + org, + baseSite, + pages, + satellites, + action, + syncMode, + onPageStatus, +}) { + const satEntries = Object.entries(satellites); + + const tasks = pages.flatMap((page) => { + const pagePath = page.path.replace(/\.html$/, ''); + + return satEntries.map(([satSite]) => async () => { + const key = `${page.path}:${satSite}`; + onPageStatus?.(key, 'pending'); + + try { + let result; + switch (action) { + case 'preview': + result = await previewSatellite(org, satSite, pagePath); + break; + case 'publish': + result = await publishSatellite(org, satSite, pagePath); + break; + case 'break': + result = await createOverride(org, baseSite, satSite, pagePath); + break; + case 'sync': + result = syncMode === 'merge' + ? await mergeFromBase(org, baseSite, satSite, pagePath) + : await createOverride(org, baseSite, satSite, pagePath); + break; + case 'reset': { + const pageStatus = await getSatellitePageStatus(org, satSite, pagePath); + result = await deleteOverride(org, satSite, pagePath); + if (!result?.error) { + if (pageStatus.live) { + await previewSatellite(org, satSite, pagePath); + await publishSatellite(org, satSite, pagePath); + } else if (pageStatus.preview) { + await previewSatellite(org, satSite, pagePath); + } + } + break; + } + default: + result = { error: `Unknown action: ${action}` }; + } + + onPageStatus?.(key, result?.error ? 'error' : 'success', result?.error); + return { key, status: result?.error ? 'error' : 'success', error: result?.error }; + } catch (e) { + onPageStatus?.(key, 'error', e.message); + return { key, status: 'error', error: e.message }; + } + }); + }); + + return runWithConcurrency(tasks, MAX_CONCURRENT); +} diff --git a/tools/apps/msm/helpers/column-browser.css b/tools/apps/msm/helpers/column-browser.css new file mode 100644 index 0000000..ed99dc4 --- /dev/null +++ b/tools/apps/msm/helpers/column-browser.css @@ -0,0 +1,286 @@ +:host { + display: block; +} + +/* ===== Browser container ===== */ + +.browser { + display: flex; + border: 1px solid var(--s2-gray-200); + border-radius: 8px; + overflow: hidden; + min-height: 320px; + max-height: 480px; + background: var(--s2-gray-50, #fafafa); +} + +/* ===== Column ===== */ + +.column { + flex: 0 0 220px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--s2-gray-200); + overflow: hidden; +} + +.column:last-child { + border-right: none; + flex: 1 1 220px; +} + +.column-header { + display: flex; + align-items: center; + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500); + background: var(--s2-gray-75, #f8f8f8); + border-bottom: 1px solid var(--s2-gray-200); + user-select: none; + min-height: 32px; + box-sizing: border-box; +} + +.column-items { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.column-items::-webkit-scrollbar { width: 5px; } +.column-items::-webkit-scrollbar-track { background: transparent; } + +.column-items::-webkit-scrollbar-thumb { + background: var(--s2-gray-300); + border-radius: 3px; +} + +.column-items::-webkit-scrollbar-thumb:hover { + background: var(--s2-gray-500); +} + +/* ===== Item row ===== */ + +.item { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + cursor: pointer; + user-select: none; + transition: background-color 0.1s; + font-size: 14px; + color: var(--s2-gray-800); + min-height: 32px; + box-sizing: border-box; +} + +.item:hover { + background: var(--s2-gray-100, #f5f5f5); +} + +.item.selected { + background: var(--s2-blue-100, #e0ecff); +} + +.item.selected:hover { + background: var(--s2-blue-200, #cce0ff); +} + +.item.focused { + outline: 2px solid var(--s2-blue-900); + outline-offset: -2px; + border-radius: 4px; +} + +.browser:focus { + outline: none; +} + +.browser:focus-visible { + outline: 2px solid var(--s2-blue-900); + outline-offset: 2px; +} + +/* ===== Checkbox ===== */ + +.item input[type="checkbox"] { + appearance: none; + width: 14px; + height: 14px; + margin: 0; + border: 2px solid var(--s2-gray-600, #717171); + border-radius: 2px; + cursor: pointer; + flex-shrink: 0; + position: relative; + transition: border 0.13s ease-in-out; +} + +.item input[type="checkbox"]:checked { + border-color: var(--s2-gray-800); + border-width: 7px; + background: var(--s2-gray-50, #f8f8f8); +} + +.item input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + inset: 0; + top: -2px; + left: -3px; + width: 4px; + height: 8px; + margin: auto; + border: solid var(--s2-gray-50, #f8f8f8); + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.item input[type="checkbox"]:focus-visible { + outline: 2px solid var(--s2-blue-900); + outline-offset: 2px; +} + +.item input[type="checkbox"]:hover:not(:disabled) { + border-color: var(--s2-gray-700); +} + +.item input[type="checkbox"]:checked:hover:not(:disabled) { + border-color: var(--s2-gray-800); +} + +/* ===== Item label and icons ===== */ + +.item-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.item-arrow { + flex-shrink: 0; + width: 12px; + height: 12px; + color: var(--s2-gray-400); +} + +.item-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + color: var(--s2-gray-500); +} + +/* ===== Override dot indicator ===== */ + +.override-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.override-dot.inherited { + background: var(--s2-gray-400); +} + +.override-dot.has-overrides { + background: var(--s2-orange-700, #e68619); +} + +/* ===== Column loading ===== */ + +.column-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + color: var(--s2-gray-500); + font-size: 13px; + font-style: italic; +} + +.column-loading .mini-spinner { + width: 14px; + height: 14px; + margin-right: 8px; + border: 2px solid var(--s2-gray-200); + border-top-color: var(--s2-gray-600); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ===== Mobile: single-column mode ===== */ + +@media (width <= 600px) { + .browser { + position: relative; + min-height: 360px; + } + + .column { + position: absolute; + inset: 0; + flex: none; + width: 100%; + border-right: none; + background: var(--s2-gray-50, #fafafa); + transform: translateX(100%); + transition: transform 0.25s ease; + z-index: 0; + } + + .column.active { + transform: translateX(0); + z-index: 1; + } + + .back-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 0; + margin-right: 8px; + background: none; + border: none; + cursor: pointer; + color: var(--s2-blue-900); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .back-btn svg { + width: 12px; + height: 12px; + } +} + +@media (width >= 601px) { + .back-btn { + display: none; + } +} + +/* ===== Column empty ===== */ + +.column-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + color: var(--s2-gray-400); + font-size: 13px; + font-style: italic; +} diff --git a/tools/apps/msm/helpers/column-browser.js b/tools/apps/msm/helpers/column-browser.js new file mode 100644 index 0000000..2b588ed --- /dev/null +++ b/tools/apps/msm/helpers/column-browser.js @@ -0,0 +1,371 @@ +/* eslint-disable no-underscore-dangle, import/no-unresolved, no-console, class-methods-use-this */ +import { LitElement, html, nothing } from 'da-lit'; +import { listFolder } from './api.js'; + +const NX = 'https://da.live/nx'; +let sheet; +try { + const { default: getStyle } = await import(`${NX}/utils/styles.js`); + sheet = await getStyle(import.meta.url); +} catch (e) { + console.warn('Failed to load column-browser styles:', e); +} + +const FOLDER_ICON = html``; +const PAGE_ICON = html``; +const ARROW_RIGHT = html``; +const BACK_ARROW = html``; + +class MsmColumnBrowser extends LitElement { + static properties = { + org: { type: String }, + role: { type: String }, + site: { type: String }, + msmConfig: { attribute: false }, + _columns: { state: true }, + _checked: { state: true }, + _activeColumnIdx: { state: true }, + _loadingColumn: { state: true }, + _focusedItemIdx: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + if (sheet) this.shadowRoot.adoptedStyleSheets = [sheet]; + this._columns = []; + this._checked = new Set(); + this._activeColumnIdx = 0; + this._loadingColumn = -1; + this._focusedItemIdx = -1; + this._handleKeydown = this._onKeydown.bind(this); + + if (this.role === 'satellite' && this.site) { + this.initSatelliteRoot(); + } else { + this.initSitesColumn(); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + } + + _onKeydown(e) { + const col = this._columns[this._activeColumnIdx]; + if (!col?.items.length) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + this._focusedItemIdx = Math.min( + this._focusedItemIdx + 1, + col.items.length - 1, + ); + this.scrollFocusedIntoView(); + break; + case 'ArrowUp': + e.preventDefault(); + this._focusedItemIdx = Math.max(this._focusedItemIdx - 1, 0); + this.scrollFocusedIntoView(); + break; + case 'ArrowRight': + case 'Enter': { + e.preventDefault(); + const item = col.items[this._focusedItemIdx]; + if (item && (item.isFolder || item.isSite)) { + this.navigateToFolder(this._activeColumnIdx, item); + this._focusedItemIdx = 0; + } + break; + } + case 'ArrowLeft': + e.preventDefault(); + if (this._activeColumnIdx > 0) { + this._activeColumnIdx -= 1; + this._focusedItemIdx = 0; + } + break; + case ' ': + e.preventDefault(); + if (this._focusedItemIdx >= 0) { + const item = col.items[this._focusedItemIdx]; + if (item && this.showCheckbox(item)) { + this.toggleCheck(item); + } + } + break; + default: + break; + } + } + + scrollFocusedIntoView() { + this.updateComplete.then(() => { + const items = this.shadowRoot.querySelectorAll( + `.column:nth-child(${this._activeColumnIdx + 1}) .item`, + ); + if (items[this._focusedItemIdx]) { + items[this._focusedItemIdx].scrollIntoView({ block: 'nearest' }); + } + }); + } + + toggleCheck(item) { + const next = new Set(this._checked); + const key = `${item.site || ''}:${item.path}`; + if (next.has(key)) next.delete(key); + else next.add(key); + this._checked = next; + const site = item.site || this.getCurrentSite(); + this.emitSelection(site); + } + + initSitesColumn() { + if (!this.msmConfig?.baseSites?.length) return; + const items = this.msmConfig.baseSites.map((bs) => ({ + name: `${this.org} / ${bs.label || bs.site}`, + path: bs.site, + isFolder: true, + isSite: true, + site: bs.site, + })); + this._columns = [{ header: 'Sites', items, selectedPath: null }]; + } + + async initSatelliteRoot() { + this._loadingColumn = 0; + this._columns = [{ + header: this.site, items: [], selectedPath: null, + }]; + + try { + const items = await listFolder(this.org, this.site, '/'); + this._columns = [{ + header: this.site, + items: items.map((i) => ({ ...i, site: this.site })), + selectedPath: null, + }]; + } catch (e) { + console.error('Failed to load satellite root:', e); + this._columns = [{ header: this.site, items: [], selectedPath: null }]; + } + this._loadingColumn = -1; + } + + async navigateToFolder(colIdx, item) { + const site = this.findSite(colIdx, item); + if (!site) return; + + const newColumns = this._columns.slice(0, colIdx + 1); + newColumns[colIdx] = { ...newColumns[colIdx], selectedPath: item.path }; + this._columns = newColumns; + this._activeColumnIdx = colIdx + 1; + this._loadingColumn = colIdx + 1; + + try { + let items; + if (item.isSite) { + items = await listFolder(this.org, site, '/'); + } else { + items = await listFolder(this.org, site, item.path); + } + items = items.map((i) => ({ ...i, site })); + + const header = item.isSite ? item.path : item.name; + this._columns = [ + ...newColumns, + { header, items, selectedPath: null }, + ]; + } catch (e) { + console.error('Failed to load folder:', e); + this._columns = [ + ...newColumns, + { header: item.name, items: [], selectedPath: null }, + ]; + } + this._loadingColumn = -1; + + this.clearChecksAfterColumn(colIdx); + this.emitSelection(site); + } + + findSite(colIdx) { + if (this.role === 'satellite' && this.site) return this.site; + + const searchCols = this._columns.slice(0, colIdx + 1).reverse(); + const match = searchCols.reduce((found, col) => { + if (found) return found; + if (col.selectedPath) { + const selItem = col.items.find((it) => it.path === col.selectedPath); + if (selItem?.site) return selItem.site; + } + const siteItem = col.items.find((it) => it.isSite && it.site); + if (siteItem && col.selectedPath === siteItem.path) return siteItem.site; + return null; + }, null); + if (match) return match; + + const firstSiteCol = this._columns[0]; + if (firstSiteCol?.selectedPath) { + const item = firstSiteCol.items.find((i) => i.path === firstSiteCol.selectedPath); + return item?.site; + } + return null; + } + + getCurrentSite() { + if (this.role === 'satellite' && this.site) return this.site; + + const cols = [...this._columns].reverse(); + const match = cols.reduce((found, col) => ( + found || col.items.find((item) => item.site) + ), null); + return match?.site || null; + } + + handleItemClick(colIdx, item, e) { + if (e?.target?.type === 'checkbox') return; + if (item.isFolder || item.isSite) { + this.navigateToFolder(colIdx, item); + } + } + + handleCheckChange(item, e) { + e.stopPropagation(); + const next = new Set(this._checked); + const key = `${item.site || ''}:${item.path}`; + if (e.target.checked) { + next.add(key); + } else { + next.delete(key); + } + this._checked = next; + + const site = item.site || this.getCurrentSite(); + this.emitSelection(site); + } + + clearChecksAfterColumn(colIdx) { + const validPaths = new Set( + this._columns.slice(0, colIdx + 1).flatMap( + (col) => col.items.map((item) => `${item.site || ''}:${item.path}`), + ), + ); + this._checked = new Set([...this._checked].filter((key) => validPaths.has(key))); + } + + emitSelection(site) { + const lastCol = this._columns[this._columns.length - 1]; + if (!lastCol) return; + + const selectedItems = lastCol.items.filter((item) => { + const key = `${item.site || site || ''}:${item.path}`; + return this._checked.has(key); + }); + + const currentPath = lastCol.header || ''; + this.dispatchEvent(new CustomEvent('browse-selection', { + detail: { selectedItems, currentPath, site: site || this.getCurrentSite() }, + bubbles: true, + composed: true, + })); + } + + goBack() { + if (this._activeColumnIdx > 0) { + this._activeColumnIdx -= 1; + } + } + + isItemChecked(item) { + const key = `${item.site || ''}:${item.path}`; + return this._checked.has(key); + } + + showCheckbox(item) { + return item.ext === 'html' || item.isFolder; + } + + renderItem(colIdx, item, itemIdx) { + const isSelected = this._columns[colIdx]?.selectedPath === item.path; + const isFocused = colIdx === this._activeColumnIdx + && itemIdx === this._focusedItemIdx; + return html` +
    this.handleItemClick(colIdx, item, e)} + role="option" + aria-selected=${isSelected} + > + ${this.showCheckbox(item) ? html` + this.handleCheckChange(item, e)} + @click=${(e) => e.stopPropagation()} + /> + ` : nothing} + ${item.isFolder || item.isSite ? FOLDER_ICON : PAGE_ICON} + ${item.name} + ${item.isFolder || item.isSite ? ARROW_RIGHT : nothing} +
    + `; + } + + renderColumn(col, colIdx) { + const isActive = colIdx === this._activeColumnIdx; + const isLoading = this._loadingColumn === colIdx; + + return html` +
    +
    + ${colIdx > 0 ? html` + + ` : nothing} + ${col.header} +
    +
    + ${isLoading ? html` +
    +
    Loading\u2026 +
    + ` : nothing} + ${!isLoading && col.items.length === 0 ? html` +
    Empty folder
    + ` : nothing} + ${!isLoading ? col.items.map( + (item, idx) => this.renderItem(colIdx, item, idx), + ) : nothing} +
    +
    + `; + } + + render() { + if (!this._columns.length) { + return html`
    No sites available
    `; + } + + const loadingNext = this._loadingColumn === this._columns.length; + + return html` +
    + ${this._columns.map((col, idx) => this.renderColumn(col, idx))} + ${loadingNext ? html` +
    +
    Loading\u2026
    +
    +
    +
    Loading\u2026 +
    +
    +
    + ` : nothing} +
    + `; + } +} + +customElements.define('msm-column-browser', MsmColumnBrowser); diff --git a/tools/apps/msm/msm.css b/tools/apps/msm/msm.css new file mode 100644 index 0000000..7c89a64 --- /dev/null +++ b/tools/apps/msm/msm.css @@ -0,0 +1,189 @@ +:host { + display: block; + max-width: 1200px; + margin: 0 auto; + padding: var(--spacing-400); + -webkit-font-smoothing: antialiased; + font-family: var(--body-font-family); + color: var(--s2-gray-800); +} + +/* ===== Header ===== */ + +.msm-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacing-400); + padding-bottom: var(--spacing-300); + border-bottom: 2px solid var(--s2-gray-200); +} + +.msm-header h1 { + margin: 0; + font-size: var(--s2-heading-size-700); + font-weight: 700; + color: var(--s2-gray-800); +} + +.msm-header-meta { + display: flex; + align-items: center; + gap: var(--spacing-200); +} + +.msm-header .site-label { + font-size: var(--s2-font-size-200); + color: var(--s2-gray-600); +} + +.msm-header .role-badge { + font-size: var(--s2-font-size-75, 11px); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 2px 8px; + border-radius: 4px; + background: var(--s2-blue-100, #deebff); + color: var(--s2-blue-900, #0747a6); +} + +/* ===== Init ===== */ + +.msm-init { + display: flex; + align-items: center; + justify-content: center; + min-height: 60vh; +} + +.msm-init-card { + width: 100%; + max-width: 420px; + padding: var(--spacing-600); + border: 1px solid var(--s2-gray-200); + border-radius: 8px; + background: var(--s2-gray-25, #fff); +} + +.msm-init-card h1 { + margin: 0 0 var(--spacing-100); + font-size: var(--s2-heading-size-700); + font-weight: 700; + color: var(--s2-gray-800); +} + +.msm-init-card p { + margin: 0 0 var(--spacing-400); + font-size: var(--s2-font-size-200); + color: var(--s2-gray-600); +} + +.msm-init-form { + display: flex; + flex-direction: column; + gap: var(--spacing-300); +} + +.msm-init-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-300); +} + +.msm-init-hint { + margin: 0; + font-size: var(--s2-font-size-75, 11px); + color: var(--s2-gray-500); + line-height: 1.4; +} + +.msm-init-field { + display: flex; + flex-direction: column; + gap: var(--spacing-100); +} + +.msm-init-field .optional { + font-weight: 400; + color: var(--s2-gray-500); +} + +.msm-init-field label { + font-size: var(--s2-font-size-100); + font-weight: 600; + color: var(--s2-gray-700); +} + +.msm-init-field input { + padding: var(--spacing-200) var(--spacing-300); + border: 1px solid var(--s2-gray-300); + border-radius: 4px; + font-size: var(--s2-font-size-200); + color: var(--s2-gray-800); + background: var(--s2-gray-50, #fafafa); + outline: none; + transition: border-color 0.15s; +} + +.msm-init-field input:focus { + border-color: var(--s2-blue-800, #1473e6); + box-shadow: 0 0 0 1px var(--s2-blue-800, #1473e6); +} + +.msm-init-field input::placeholder { + color: var(--s2-gray-500); +} + +.msm-init-error { + padding: var(--spacing-200) var(--spacing-300); + border-radius: 4px; + background: var(--s2-red-100, #ffe0e0); + color: var(--s2-red-900, #a10000); + font-size: var(--s2-font-size-100); +} + +/* ===== Loading / Empty ===== */ + +.msm-loading, +.msm-empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + font-size: var(--s2-font-size-200); + color: var(--s2-gray-600); + font-style: italic; +} + +.msm-empty { + flex-direction: column; + gap: var(--spacing-300); + font-style: normal; +} + +.msm-empty p { + margin: 0; +} + +.msm-loading .spinner { + width: 20px; + height: 20px; + margin-right: var(--spacing-200); + border: 2px solid var(--s2-gray-300); + border-top-color: var(--s2-gray-800); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ===== Layout ===== */ + +.msm-body { + display: flex; + flex-direction: column; + gap: var(--spacing-400); +} diff --git a/tools/apps/msm/msm.html b/tools/apps/msm/msm.html new file mode 100644 index 0000000..845cfde --- /dev/null +++ b/tools/apps/msm/msm.html @@ -0,0 +1,21 @@ + + + + MSM Actions + + + + + + + + + + + + + + + diff --git a/tools/apps/msm/msm.js b/tools/apps/msm/msm.js new file mode 100644 index 0000000..09de724 --- /dev/null +++ b/tools/apps/msm/msm.js @@ -0,0 +1,273 @@ +/* eslint-disable no-underscore-dangle, import/no-unresolved, no-console, class-methods-use-this */ +import DA_SDK from 'https://da.live/nx/utils/sdk.js'; +import { LitElement, html, nothing } from 'da-lit'; +import { fetchMsmConfig, checkPageOverrides } from './helpers/api.js'; +import './helpers/column-browser.js'; +import './helpers/action-panel.js'; + +const NX = 'https://da.live/nx'; +let sl = null; +let styles = null; +let buttons = null; +try { + const { default: getStyle } = await import(`${NX}/utils/styles.js`); + [sl, styles, buttons] = await Promise.all([ + getStyle(`${NX}/public/sl/styles.css`), + getStyle(import.meta.url), + getStyle(`${NX}/styles/buttons.css`), + ]); +} catch (e) { + console.warn('Failed to load styles:', e); +} + +class MsmApp extends LitElement { + static properties = { + context: { attribute: false }, + token: { attribute: false }, + _state: { state: true }, + _org: { state: true }, + _site: { state: true }, + _role: { state: true }, + _baseSite: { state: true }, + _msmConfig: { state: true }, + _selectedItems: { state: true }, + _currentPath: { state: true }, + _satellites: { state: true }, + _pageOverrides: { state: true }, + _initError: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [sl, styles, buttons].filter(Boolean); + this._selectedItems = []; + this._currentPath = ''; + this._satellites = {}; + this._pageOverrides = new Map(); + this._initError = ''; + this._role = 'base'; + + const org = this.context?.org; + const site = this.context?.repo; + if (org) { + this._org = org; + this._site = site || ''; + this._state = 'loading'; + this.loadConfig(org); + } else { + this._state = 'init'; + } + } + + handleOrgSubmit(e) { + e.preventDefault(); + const orgInput = this.shadowRoot.querySelector('#org-input'); + const siteInput = this.shadowRoot.querySelector('#site-input'); + const org = (orgInput?.value || '').trim().replace(/^\/+/, ''); + const site = (siteInput?.value || '').trim().replace(/^\/+/, ''); + if (!org) return; + this._org = org; + this._site = site; + this._initError = ''; + this._state = 'loading'; + this.loadConfig(org); + } + + classifySite(config) { + if (!this._site) { + this._role = 'base'; + return; + } + + const isSatellite = config.baseSites.find( + (bs) => Object.keys(bs.satellites).includes(this._site), + ); + if (isSatellite) { + this._role = 'satellite'; + this._baseSite = isSatellite.site; + this._satellites = { + [this._site]: isSatellite.satellites[this._site], + }; + return; + } + + this._role = 'base'; + } + + async loadConfig(org) { + try { + const config = await fetchMsmConfig(org); + if (!config || !config.baseSites.length) { + this._state = 'no-config'; + return; + } + this._msmConfig = config; + this.classifySite(config); + this._state = 'ready'; + } catch (e) { + console.error('Failed to load MSM config:', e); + this._initError = `Could not load MSM configuration for "${org}".`; + this._state = 'init'; + } + } + + handleBrowseSelection(e) { + const { selectedItems, currentPath, site } = e.detail; + this._selectedItems = selectedItems; + this._currentPath = currentPath; + this._currentSite = site; + + if (this._role === 'satellite') return; + + if (selectedItems.length > 0 && this._msmConfig) { + const baseSite = this._msmConfig.baseSites.find((s) => s.site === site); + if (baseSite) { + this._satellites = baseSite.satellites; + this.loadOverrides(selectedItems); + } + } else { + this._pageOverrides = new Map(); + } + } + + async loadOverrides(items) { + const pages = items.filter((i) => i.ext === 'html'); + if (!pages.length) return; + + const org = this._org; + const sats = this._satellites; + const overrides = new Map(); + + await Promise.all(pages.map(async (page) => { + const pagePath = page.path.replace('.html', ''); + const results = await checkPageOverrides(org, sats, pagePath); + overrides.set(page.path, results); + })); + + this._pageOverrides = new Map(overrides); + } + + get _selectedPages() { + return this._selectedItems.filter((i) => i.ext === 'html'); + } + + get _isSinglePage() { + return this._selectedPages.length === 1; + } + + renderHeader() { + const org = this._org || ''; + const site = this._role === 'satellite' ? this._site : (this._currentSite || ''); + return html` +
    +

    MSM Actions

    +
    + ${site ? html`${org} / ${site}` : nothing} + ${this._role === 'satellite' ? html`Satellite` : nothing} +
    +
    + `; + } + + renderLoading() { + return html` +
    +
    + Loading MSM configuration\u2026 +
    + `; + } + + renderInit() { + return html` +
    +
    +

    MSM Actions

    +

    Enter your organization and, optionally, a site name.

    +
    +
    +
    + + +
    +
    + + +
    +
    +

    + Leave site blank to manage all base sites. + Enter a satellite site name to preview and publish your content. +

    + ${this._initError ? html` + + ` : nothing} + +
    +
    +
    + `; + } + + renderEmpty() { + return html` +
    +

    No MSM base sites configured for ${this._org}.

    + +
    + `; + } + + render() { + if (this._state === 'init') return this.renderInit(); + if (this._state === 'loading') return this.renderLoading(); + if (this._state === 'no-config') return this.renderEmpty(); + + return html` + ${this.renderHeader()} +
    + + ${this._selectedPages.length > 0 ? html` + + ` : nothing} +
    + `; + } +} + +customElements.define('msm-app', MsmApp); + +(async function init() { + const { context, token } = await DA_SDK; + const cmp = document.createElement('msm-app'); + cmp.context = context; + cmp.token = token; + document.body.append(cmp); +}()); From 5a648da188fe99b1b40075d137e906028d391a2b Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Fri, 10 Apr 2026 17:30:11 -0400 Subject: [PATCH 02/26] msm app updated --- tools/apps/msm/helpers/action-panel.css | 295 +++++++++++++--------- tools/apps/msm/helpers/action-panel.js | 190 ++++++++++---- tools/apps/msm/helpers/api.js | 45 ++-- tools/apps/msm/helpers/column-browser.css | 53 ++-- tools/apps/msm/helpers/column-browser.js | 89 +++++-- tools/apps/msm/msm.css | 162 +++--------- tools/apps/msm/msm.html | 2 +- tools/apps/msm/msm.js | 152 +++++------ 8 files changed, 552 insertions(+), 436 deletions(-) diff --git a/tools/apps/msm/helpers/action-panel.css b/tools/apps/msm/helpers/action-panel.css index faaf384..f04f607 100644 --- a/tools/apps/msm/helpers/action-panel.css +++ b/tools/apps/msm/helpers/action-panel.css @@ -9,6 +9,7 @@ border-radius: 8px; background: #fff; overflow: hidden; + min-height: 370px; } .panel-header { @@ -77,42 +78,61 @@ } .sat-tag.active { - background: var(--s2-blue-100, #e0ecff); - border-color: var(--s2-blue-400, #96b8f6); - color: var(--s2-blue-900); + background: var(--s2-gray-75, #f8f8f8); } -.sat-tag input[type="checkbox"] { +/* ===== Checkbox (Spectrum 2) ===== */ + +.sat-tag input[type="checkbox"], +.sat-row input[type="checkbox"], +.page-table input[type="checkbox"] { appearance: none; - width: 12px; - height: 12px; + width: 14px; + height: 14px; margin: 0; - border: 1.5px solid currentcolor; + border: 2px solid var(--s2-gray-600); border-radius: 2px; - position: relative; cursor: pointer; flex-shrink: 0; + position: relative; + background: transparent; + transition: background-color 0.13s, border-color 0.13s; + vertical-align: middle; } -.sat-tag input[type="checkbox"]:checked { - border-width: 6px; - border-color: var(--s2-blue-900); +.sat-tag input[type="checkbox"]:checked, +.sat-row input[type="checkbox"]:checked, +.page-table input[type="checkbox"]:checked { + background: var(--s2-gray-800); + border-color: var(--s2-gray-800); } -.sat-tag input[type="checkbox"]:checked::after { +.sat-tag input[type="checkbox"]:checked::after, +.sat-row input[type="checkbox"]:checked::after, +.page-table input[type="checkbox"]:checked::after { content: ''; position: absolute; inset: 0; - top: -2px; - left: -2px; - width: 3px; - height: 6px; margin: auto; + margin-top: -1px; + width: 4px; + height: 8px; border: solid #fff; - border-width: 0 1.5px 1.5px 0; + border-width: 0 2px 2px 0; transform: rotate(45deg); } +.page-table input[type="checkbox"]:focus-visible { + outline: 2px solid var(--s2-blue-900); + outline-offset: 2px; +} + +.sat-tag input[type="checkbox"]:hover:not(:disabled), +.sat-row input[type="checkbox"]:hover:not(:disabled), +.page-table input[type="checkbox"]:hover:not(:disabled) { + border-color: var(--s2-gray-800); +} + /* ===== Action row (global controls) ===== */ .action-row { @@ -146,8 +166,7 @@ align-items: center; justify-content: space-between; gap: 8px; - width: 100%; - min-width: 160px; + min-width: 180px; height: 32px; padding: 0 10px; background: var(--s2-gray-75, #f8f8f8); @@ -161,6 +180,10 @@ transition: border-color 0.15s, background-color 0.15s; } +.page-action-pickers .picker-trigger { + flex: 1; +} + .picker-trigger:disabled { background: var(--s2-gray-75); border-color: var(--s2-gray-200); @@ -198,7 +221,6 @@ position: absolute; top: calc(100% + 4px); left: 0; - right: 0; z-index: 10; list-style: none; margin: 0; @@ -257,59 +279,6 @@ visibility: visible; } -/* ===== Buttons ===== */ - -.btn { - appearance: none; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - height: 32px; - padding: 0 16px; - font-family: inherit; - font-size: 14px; - font-weight: 500; - border-radius: 8px; - cursor: pointer; - white-space: nowrap; - transition: background-color 0.15s, border-color 0.15s, opacity 0.15s; - border: 1px solid var(--s2-gray-300); - background: #fff; - color: var(--s2-gray-800); -} - -.btn:disabled { - opacity: 0.4; - cursor: default; -} - -.btn:hover:not(:disabled) { - border-color: var(--s2-gray-500); - background: var(--s2-gray-100); -} - -.btn.primary { - background: var(--s2-blue-900, #3b63fb); - border-color: var(--s2-blue-900, #3b63fb); - color: #fff; -} - -.btn.primary:hover:not(:disabled) { - background: var(--s2-blue-1000, #2d4ec2); - border-color: var(--s2-blue-1000, #2d4ec2); -} - -.btn.danger { - color: var(--s2-red-900, #d31510); - border-color: #f0c8c2; -} - -.btn.danger:hover:not(:disabled) { - background: #fef0ee; - border-color: var(--s2-red-900); -} - /* ===== Satellite list (single-page mode) ===== */ .satellite-grid { @@ -385,45 +354,11 @@ white-space: nowrap; } -/* stylelint-disable no-descending-specificity */ -.sat-row input[type="checkbox"] { - appearance: none; - width: 14px; - height: 14px; - margin: 0; - border: 2px solid var(--s2-gray-600); - border-radius: 2px; - cursor: pointer; - flex-shrink: 0; - position: relative; - transition: border 0.13s ease-in-out; -} - -.sat-row input[type="checkbox"]:checked { - border-color: var(--s2-gray-800); - border-width: 7px; - background: var(--s2-gray-50, #f8f8f8); -} - -.sat-row input[type="checkbox"]:checked::after { - content: ''; - position: absolute; - inset: 0; - top: -2px; - left: -3px; - width: 4px; - height: 8px; - margin: auto; - border: solid var(--s2-gray-50, #f8f8f8); - border-width: 0 2px 2px 0; - transform: rotate(45deg); -} -/* stylelint-enable no-descending-specificity */ - /* ===== Page table (bulk mode) ===== */ .page-table { width: 100%; + table-layout: fixed; border-collapse: separate; border-spacing: 0; } @@ -447,6 +382,14 @@ vertical-align: middle; } +.page-table td:has(.page-name-cell) { + height: 1px; +} + +.page-table td:has(.row-actions) { + text-align: right; +} + .page-table tr:last-child td { border-bottom: none; } @@ -455,8 +398,32 @@ background: var(--s2-gray-50); } +.page-action-pickers { + display: flex; + align-items: center; + gap: 6px; +} + +.sync-picker-slot.hidden { + visibility: hidden; + pointer-events: none; +} + +.page-name-cell { + display: flex; + align-items: center; + gap: 6px; + height: 100%; + cursor: pointer; +} + .page-name { + flex: 1; + min-width: 0; font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .override-badge { @@ -484,13 +451,15 @@ .row-actions { display: flex; align-items: center; + justify-content: flex-end; gap: 8px; + white-space: nowrap; } /* ===== Expandable row detail ===== */ .expand-row td { /* stylelint-disable-line no-descending-specificity */ - padding: 0 8px 12px 32px; + padding: 12px 8px 12px 32px; background: var(--s2-gray-50); } @@ -550,24 +519,11 @@ /* ===== Confirm dialog ===== */ -.confirm-box { - padding: 12px; - background: #fef9ee; - border: 1px solid #f0dca0; - border-radius: 8px; - font-size: 14px; - color: var(--s2-gray-900); -} - -.confirm-box p { - margin: 0 0 10px; - line-height: 1.4; -} - .confirm-actions { display: flex; gap: 8px; justify-content: flex-end; + margin-top: 12px; } /* ===== Progress view ===== */ @@ -653,7 +609,23 @@ /* ===== Responsive ===== */ +@media (width <= 900px) { + .picker-trigger { + min-width: 140px; + } + + .page-action-pickers { + flex-direction: column; + align-items: stretch; + } +} + @media (width <= 600px) { + .panel-body { + padding: 12px; + gap: 12px; + } + .action-row { flex-direction: column; align-items: stretch; @@ -667,8 +639,83 @@ flex-direction: column; } + .picker-trigger { + min-width: 0; + width: 100%; + } + + /* Page table → card layout */ .page-table { - font-size: 13px; + display: block; + } + + .page-table thead { + display: none; + } + + .page-table tbody { + display: flex; + flex-direction: column; + gap: 8px; + } + + .page-table tr { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + padding: 10px 12px; + border: 1px solid var(--s2-gray-200); + border-radius: 8px; + } + + .page-table td { + display: block; + padding: 0; + border: none; + } + + .page-table tr:hover td { + background: transparent; + } + + .page-table tr:hover { + background: var(--s2-gray-50); + } + + .page-name-cell { + flex: 1; + min-width: 0; + } + + .page-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .page-action-pickers { + flex-flow: row wrap; + gap: 6px; + width: 100%; + } + + .page-action-pickers .form-row { + flex: 1; + min-width: 0; + } + + .page-action-pickers .picker-trigger { + width: 100%; + } + + .row-actions { + margin-left: auto; + } + + .expand-row td { /* stylelint-disable-line no-descending-specificity */ + padding: 4px 12px 12px; + width: 100%; } .form-actions { diff --git a/tools/apps/msm/helpers/action-panel.js b/tools/apps/msm/helpers/action-panel.js index defe4e0..2fe33a4 100644 --- a/tools/apps/msm/helpers/action-panel.js +++ b/tools/apps/msm/helpers/action-panel.js @@ -3,10 +3,16 @@ import { LitElement, html, nothing } from 'da-lit'; import { executeBulkAction } from './api.js'; const NX = 'https://da.live/nx'; +let sl; let sheet; +let buttons; try { const { default: getStyle } = await import(`${NX}/utils/styles.js`); - sheet = await getStyle(import.meta.url); + [sl, sheet, buttons] = await Promise.all([ + getStyle(`${NX}/public/sl/styles.css`), + getStyle(import.meta.url), + getStyle(`${NX}/styles/buttons.css`), + ]); } catch (e) { console.warn('Failed to load action-panel styles:', e); } @@ -16,7 +22,7 @@ const CHECK_ICON = html``; const SUCCESS_ICON = html``; const ERROR_ICON = html``; -const EDIT_ICON = html``; +const EDIT_ICON = html``; const BASE_ACTION_OPTIONS = [ { @@ -75,6 +81,7 @@ class MsmActionPanel extends LitElement { _globalAction: { state: true }, _globalSyncMode: { state: true }, _pageActions: { state: true }, + _pageSyncModes: { state: true }, _selectedSats: { state: true }, _singleSelectedSats: { state: true }, _openPicker: { state: true }, @@ -83,14 +90,16 @@ class MsmActionPanel extends LitElement { _executing: { state: true }, _taskStatuses: { state: true }, _busy: { state: true }, + _includedPages: { state: true }, }; connectedCallback() { super.connectedCallback(); - if (sheet) this.shadowRoot.adoptedStyleSheets = [sheet]; + this.shadowRoot.adoptedStyleSheets = [sl, sheet, buttons].filter(Boolean); this._globalAction = 'preview'; this._globalSyncMode = 'merge'; this._pageActions = new Map(); + this._pageSyncModes = new Map(); this._selectedSats = new Set(Object.keys(this.satellites || {})); this._singleSelectedSats = new Set(Object.keys(this.satellites || {})); this._openPicker = null; @@ -99,6 +108,8 @@ class MsmActionPanel extends LitElement { this._executing = false; this._taskStatuses = new Map(); this._busy = false; + this._includedPages = new Set(this.pages?.map((p) => p.path) || []); + this._lastPageKey = ''; this._handleOutsideClick = this._handleOutsideClick.bind(this); } @@ -116,7 +127,14 @@ class MsmActionPanel extends LitElement { this._executing = false; this._taskStatuses = new Map(); this._pageActions = new Map(); + this._pageSyncModes = new Map(); this._expandedRows = new Set(); + + const newKey = this.pages?.map((p) => p.path).sort().join(',') || ''; + if (newKey !== this._lastPageKey) { + this._lastPageKey = newKey; + this._includedPages = new Set(this.pages?.map((p) => p.path) || []); + } } } @@ -183,6 +201,20 @@ class MsmActionPanel extends LitElement { this._pageActions = next; } + getPageSyncMode(pagePath) { + return this._pageSyncModes.get(pagePath) || this._globalSyncMode; + } + + setPageSyncMode(pagePath, value) { + const next = new Map(this._pageSyncModes); + if (value === this._globalSyncMode) { + next.delete(pagePath); + } else { + next.set(pagePath, value); + } + this._pageSyncModes = next; + } + // ── Expand/collapse rows ── toggleRow(pagePath) { @@ -192,6 +224,27 @@ class MsmActionPanel extends LitElement { this._expandedRows = next; } + // ── Page include/exclude ── + + togglePageInclude(pagePath) { + const next = new Set(this._includedPages); + if (next.has(pagePath)) next.delete(pagePath); + else next.add(pagePath); + this._includedPages = next; + } + + toggleAllPages() { + if (this._includedPages.size === this.pages.length) { + this._includedPages = new Set(); + } else { + this._includedPages = new Set(this.pages.map((p) => p.path)); + } + } + + get _activePages() { + return this.pages.filter((p) => this._includedPages.has(p.path)); + } + // ── Override helpers ── getPageOverrides(pagePath) { @@ -199,7 +252,8 @@ class MsmActionPanel extends LitElement { } getOverrideSummary(pagePath) { - const ov = this.getPageOverrides(pagePath); + const ov = this.getPageOverrides(pagePath) + .filter((o) => this._selectedSats.has(o.site)); if (!ov.length) return { inherited: 0, custom: 0 }; const custom = ov.filter((o) => o.hasOverride).length; return { inherited: ov.length - custom, custom }; @@ -224,7 +278,7 @@ class MsmActionPanel extends LitElement { } buildExecutionSummary() { - const counts = this.pages.reduce((acc, page) => { + const counts = this._activePages.reduce((acc, page) => { const action = this.getPageAction(page.path); acc[action] = (acc[action] || 0) + 1; return acc; @@ -246,10 +300,12 @@ class MsmActionPanel extends LitElement { this._busy = true; this._taskStatuses = new Map(); - const actionGroups = this.pages.reduce((acc, page) => { + const actionGroups = this._activePages.reduce((acc, page) => { const action = this.getPageAction(page.path); - if (!acc.has(action)) acc.set(action, []); - acc.get(action).push(page); + const syncMode = action === 'sync' ? this.getPageSyncMode(page.path) : ''; + const key = syncMode ? `${action}:${syncMode}` : action; + if (!acc.has(key)) acc.set(key, { action, syncMode, pages: [] }); + acc.get(key).pages.push(page); return acc; }, new Map()); @@ -259,21 +315,21 @@ class MsmActionPanel extends LitElement { this._taskStatuses = next; }; - const groupEntries = [...actionGroups.entries()] - .filter(([action]) => { + const groupEntries = [...actionGroups.values()] + .filter(({ action }) => { const filteredSats = this.getFilteredSatellites(action); return Object.keys(filteredSats).length > 0; }); - await groupEntries.reduce((chain, [action, groupPages]) => chain.then(() => { + await groupEntries.reduce((chain, { action, syncMode, pages }) => chain.then(() => { const filteredSats = this.getFilteredSatellites(action); return executeBulkAction({ org: this.org, baseSite: this.site, - pages: groupPages, + pages, satellites: filteredSats, action, - syncMode: action === 'sync' ? this._globalSyncMode : undefined, + syncMode: syncMode || undefined, onPageStatus: statusCallback, }); }), Promise.resolve()); @@ -298,16 +354,19 @@ class MsmActionPanel extends LitElement { this.doExecuteSingle(page, action); } - async doExecuteSingle(page, action) { + async doExecuteSingle(page, action, satSet, syncMode) { this._confirmAction = null; this._executing = true; this._busy = true; this._taskStatuses = new Map(); + const activeSats = satSet || this._singleSelectedSats; const sats = Object.entries(this.satellites || {}) - .filter(([s]) => this._singleSelectedSats.has(s)) + .filter(([s]) => activeSats.has(s)) .reduce((acc, [s, info]) => { acc[s] = info; return acc; }, {}); + const resolvedSyncMode = syncMode || this._globalSyncMode; + await executeBulkAction({ org: this.org, baseSite: this.site, @@ -315,7 +374,7 @@ class MsmActionPanel extends LitElement { satellites: sats, action, syncMode: action === 'sync' || action === 'sync-from-base' - ? this._globalSyncMode : undefined, + ? resolvedSyncMode : undefined, onPageStatus: (key, status, error) => { const next = new Map(this._taskStatuses); next.set(key, { status, error }); @@ -330,14 +389,15 @@ class MsmActionPanel extends LitElement { if (this._busy) return; const action = this.getPageAction(page.path); + const syncMode = this.getPageSyncMode(page.path); if (action === 'reset') { this._confirmAction = { message: `Resume inheritance for ${page.name}? This deletes local overrides.`, - onConfirm: () => this.doExecuteSingle(page, action), + onConfirm: () => this.doExecuteSingle(page, action, this._selectedSats, syncMode), }; return; } - this.doExecuteSingle(page, action); + this.doExecuteSingle(page, action, this._selectedSats, syncMode); } // ── Status icon helper ── @@ -374,7 +434,7 @@ class MsmActionPanel extends LitElement { return html`
    - + ${label ? html`` : nothing}
    - + this.cancelConfirm()}>Cancel + this._confirmAction.onConfirm()}>Confirm
    -
    + `; } @@ -467,11 +528,11 @@ class MsmActionPanel extends LitElement { ${this._executing ? this.renderProgress() : nothing}
    - +
    @@ -532,7 +593,7 @@ class MsmActionPanel extends LitElement { return html`
    -

    ${this.pages.length} pages selected

    +

    ${this._includedPages.size} of ${this.pages.length} pages selected

    ${this.site}
    @@ -541,11 +602,11 @@ class MsmActionPanel extends LitElement { ${this.renderConfirm()} ${this._executing ? this.renderProgress() : this.renderPageTable()}
    - +
    @@ -579,7 +640,11 @@ class MsmActionPanel extends LitElement { 'Action for all', this._globalAction, this._actionOptions, - (v) => { this._globalAction = v; this._pageActions = new Map(); }, + (v) => { + this._globalAction = v; + this._pageActions = new Map(); + this._pageSyncModes = new Map(); + }, )} ${this._globalAction === 'sync' ? this.renderPicker( 'globalSyncMode', @@ -593,11 +658,25 @@ class MsmActionPanel extends LitElement { } renderPageTable() { + const allChecked = this._includedPages.size === this.pages.length; + const someChecked = this._includedPages.size > 0 && !allChecked; return html` + + + + ${this._isSatellite ? nothing : html``} + + + - ${this._isSatellite ? nothing : html``} + ${this._isSatellite ? nothing : html``} @@ -619,16 +698,23 @@ class MsmActionPanel extends LitElement { return html` - ${this._isSatellite ? nothing : html` - - `} - + + ${this._isSatellite ? nothing : html` `} @@ -663,13 +760,14 @@ class MsmActionPanel extends LitElement { } renderExpandedRow(page) { - const overrides = this.getPageOverrides(page.path); + const overrides = this.getPageOverrides(page.path) + .filter((o) => this._selectedSats.has(o.site)); const inherited = overrides.filter((o) => !o.hasOverride); const custom = overrides.filter((o) => o.hasOverride); return html` - - - ${this._isSatellite ? nothing : html` - `} - - From 5f9c1d4cc4496da2ceb273d196e23925dfc010d6 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Mon, 13 Apr 2026 12:33:12 -0400 Subject: [PATCH 05/26] some UX udpates --- tools/apps/msm/helpers/action-panel.css | 84 +++++++++++++++++++++++-- tools/apps/msm/helpers/action-panel.js | 55 +++++++++------- tools/apps/msm/helpers/api.js | 21 ++++++- tools/apps/msm/msm.js | 3 + 4 files changed, 137 insertions(+), 26 deletions(-) diff --git a/tools/apps/msm/helpers/action-panel.css b/tools/apps/msm/helpers/action-panel.css index 4df7ce2..7f95abc 100644 --- a/tools/apps/msm/helpers/action-panel.css +++ b/tools/apps/msm/helpers/action-panel.css @@ -9,7 +9,7 @@ border-radius: 8px; background: #fff; overflow: hidden; - min-height: 370px; + min-height: 400px; } .panel-header { @@ -133,6 +133,7 @@ border-color: var(--s2-gray-800); } + /* ===== Action row (global controls) ===== */ .action-row { @@ -503,13 +504,88 @@ to { transform: rotate(360deg); } } -/* ===== Confirm dialog ===== */ +/* ===== Alert dialog (inline, Spectrum 2) ===== */ + +.alert-dialog { + background-color: #fff; + border: 1px solid transparent; + border-radius: 16px; + padding: 32px; + box-shadow: 0 1px 4px rgb(0 0 0 / 8%), 0 8px 20px rgb(0 0 0 / 10%); +} + +.alert-dialog-heading { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 16px; + font-size: 18px; + font-weight: 700; + line-height: 1.25; + color: var(--s2-gray-900); +} + +.alert-dialog-content { + margin: 0 0 32px; + font-size: 14px; + color: var(--s2-gray-700); + line-height: 1.5; +} -.confirm-actions { +.alert-dialog-buttons { display: flex; gap: 8px; justify-content: flex-end; - margin-top: 12px; +} + +/* S2 button base */ +.s2-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + min-width: 72px; + padding: 0 16px; + border: 2px solid transparent; + border-radius: 999px; + font-family: inherit; + font-size: 14px; + font-weight: 700; + line-height: 1; + cursor: pointer; + transition: background-color 0.13s, border-color 0.13s, color 0.13s; +} + +/* S2 secondary outline */ +.s2-btn-outline { + background: transparent; + border-color: var(--s2-gray-800, #4b4b4b); + color: var(--s2-gray-800, #4b4b4b); +} + +.s2-btn-outline:hover { + background: var(--s2-gray-200, #e6e6e6); +} + +.s2-btn-outline:active { + background: var(--s2-gray-300, #d5d5d5); +} + +/* S2 negative fill */ +.s2-btn-negative { + background: var(--s2-red-900, #c9252d); + border-color: var(--s2-red-900, #c9252d); + color: #fff; +} + +.s2-btn-negative:hover { + background: var(--s2-red-1000, #a41623); + border-color: var(--s2-red-1000, #a41623); +} + +.s2-btn-negative:active { + background: var(--s2-red-1100, #8c0f1e); + border-color: var(--s2-red-1100, #8c0f1e); } /* ===== Progress view ===== */ diff --git a/tools/apps/msm/helpers/action-panel.js b/tools/apps/msm/helpers/action-panel.js index 9bfe349..8b38fc7 100644 --- a/tools/apps/msm/helpers/action-panel.js +++ b/tools/apps/msm/helpers/action-panel.js @@ -259,6 +259,17 @@ class MsmActionPanel extends LitElement { return { inherited: ov.length - custom, custom }; } + hasApplicableSats(pagePath, action) { + if (this._isSatellite) return true; + const scope = ACTION_SCOPE[action]; + if (!scope) return true; + const ov = this.getPageOverrides(pagePath); + const sats = this.isSinglePage ? this._singleSelectedSats : this._selectedSats; + return ov + .filter((o) => sats.has(o.site)) + .some((o) => (scope === 'custom' ? o.hasOverride : !o.hasOverride)); + } + getFilteredSatellites() { return Object.entries(this.satellites || {}).filter(([satSite]) => ( this._selectedSats.has(satSite) @@ -284,10 +295,10 @@ class MsmActionPanel extends LitElement { return acc; }, {}); const parts = Object.entries(counts).map( - ([a, c]) => `${getActionLabel(a)} ${c} page${c > 1 ? 's' : ''}`, + ([a, c]) => `${getActionLabel(a)} ${c} page${c > 1 ? 's' : ''} (${ACTION_SCOPE[a]} sites only)`, ); const satCount = this._selectedSats.size; - return `${parts.join(', ')} across ${satCount} satellite${satCount !== 1 ? 's' : ''}. Continue?`; + return `${parts.join(', ')} across ${satCount} satellite${satCount !== 1 ? 's' : ''}. Satellites that don't match the action scope will be skipped. Continue?`; } cancelConfirm() { @@ -315,14 +326,11 @@ class MsmActionPanel extends LitElement { this._taskStatuses = next; }; - const groupEntries = [...actionGroups.values()] - .filter(({ action }) => { - const filteredSats = this.getFilteredSatellites(action); - return Object.keys(filteredSats).length > 0; - }); + const groupEntries = [...actionGroups.values()]; + const filteredSats = this.getFilteredSatellites(); await groupEntries.reduce((chain, { action, syncMode, pages }) => chain.then(() => { - const filteredSats = this.getFilteredSatellites(action); + const scope = this._isSatellite ? null : ACTION_SCOPE[action]; return executeBulkAction({ org: this.org, baseSite: this.site, @@ -330,6 +338,8 @@ class MsmActionPanel extends LitElement { satellites: filteredSats, action, syncMode: syncMode || undefined, + scope, + overrides: this.overrides, onPageStatus: statusCallback, }); }), Promise.resolve()); @@ -366,6 +376,7 @@ class MsmActionPanel extends LitElement { .reduce((acc, [s, info]) => { acc[s] = info; return acc; }, {}); const resolvedSyncMode = syncMode || this._globalSyncMode; + const scope = this._isSatellite ? null : ACTION_SCOPE[action]; await executeBulkAction({ org: this.org, @@ -375,6 +386,8 @@ class MsmActionPanel extends LitElement { action, syncMode: action === 'sync' || action === 'sync-from-base' ? resolvedSyncMode : undefined, + scope, + overrides: this.overrides, onPageStatus: (key, status, error) => { const next = new Map(this._taskStatuses); next.set(key, { status, error }); @@ -479,14 +492,14 @@ class MsmActionPanel extends LitElement { renderConfirm() { if (!this._confirmAction) return nothing; return html` - this.cancelConfirm()}> -

    ${this._confirmAction.message}

    -
    - this.cancelConfirm()}>Cancel - this._confirmAction.onConfirm()}>Confirm +
    +

    Confirm action

    +

    ${this._confirmAction.message}

    +
    + +
    - +
    `; } @@ -503,7 +516,7 @@ class MsmActionPanel extends LitElement { return html`
    -

    ${this._isSatellite ? '' : 'MSM: '}${page.name}

    +

    ${page.name}

    @@ -522,7 +535,6 @@ class MsmActionPanel extends LitElement { (v) => { this._globalSyncMode = v; }, ) : nothing}
    - ${this._isSatellite ? nothing : this.renderSatelliteGrid(inherited, custom)} ${this.renderConfirm()} ${this._executing ? this.renderProgress() : nothing} @@ -530,7 +542,8 @@ class MsmActionPanel extends LitElement {
    this.executeSinglePage()} - ?disabled=${this._busy || this._singleSelectedSats.size === 0}> + ?disabled=${this._busy || this._singleSelectedSats.size === 0 + || !this.hasApplicableSats(this.pages[0]?.path, this._globalAction)}> Apply
    @@ -599,13 +612,13 @@ class MsmActionPanel extends LitElement {
    ${this._isSatellite ? nothing : this.renderSatelliteFilter()} ${this.renderGlobalActionBar()} - ${this.renderConfirm()} ${this._executing ? this.renderProgress() : this.renderPageTable()} + ${this.renderConfirm()}
    this.executeAll()} ?disabled=${this._busy || this._selectedSats.size === 0 || this._includedPages.size === 0}> - Execute All + Apply All
    @@ -751,7 +764,7 @@ class MsmActionPanel extends LitElement {
    diff --git a/tools/apps/msm/helpers/api.js b/tools/apps/msm/helpers/api.js index d3447e7..acdd5ac 100644 --- a/tools/apps/msm/helpers/api.js +++ b/tools/apps/msm/helpers/api.js @@ -242,14 +242,33 @@ export async function executeBulkAction({ satellites, action, syncMode, + scope, + overrides, onPageStatus, + onSkipped, }) { const satEntries = Object.entries(satellites); const tasks = pages.flatMap((page) => { const pagePath = page.path.replace(/\.html$/, ''); + const pageOverrides = overrides?.get(page.path) || []; + + const applicableSats = scope + ? satEntries.filter(([satSite]) => { + const ov = pageOverrides.find((o) => o.site === satSite); + const hasOverride = ov?.hasOverride ?? false; + return scope === 'custom' ? hasOverride : !hasOverride; + }) + : satEntries; + + if (applicableSats.length < satEntries.length) { + const skipped = satEntries + .filter(([s]) => !applicableSats.some(([a]) => a === s)) + .map(([s]) => s); + skipped.forEach((s) => onSkipped?.(page, s, scope)); + } - return satEntries.map(([satSite]) => async () => { + return applicableSats.map(([satSite]) => async () => { const key = `${page.path}:${satSite}`; onPageStatus?.(key, 'pending'); diff --git a/tools/apps/msm/msm.js b/tools/apps/msm/msm.js index 6123ce3..b6e1312 100644 --- a/tools/apps/msm/msm.js +++ b/tools/apps/msm/msm.js @@ -60,6 +60,9 @@ class MsmApp extends LitElement { this._org = org; this._site = site || ''; this._initError = ''; + this._selectedItems = []; + this._currentPath = ''; + this._pageOverrides = new Map(); this._state = 'loading'; this.loadConfig(org); } From 849b603c3c32adf40e83e09d583590440359750d Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Mon, 13 Apr 2026 13:01:06 -0400 Subject: [PATCH 06/26] added more actions to satellite sites --- tools/apps/msm/helpers/action-panel.css | 2 +- tools/apps/msm/helpers/action-panel.js | 40 +++++++++++++++++-------- tools/apps/msm/msm.js | 1 + 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/tools/apps/msm/helpers/action-panel.css b/tools/apps/msm/helpers/action-panel.css index 7f95abc..eb2d77f 100644 --- a/tools/apps/msm/helpers/action-panel.css +++ b/tools/apps/msm/helpers/action-panel.css @@ -664,7 +664,7 @@ .form-actions { display: flex; - justify-content: flex-end; + justify-content: flex-start; gap: 8px; padding-top: 8px; } diff --git a/tools/apps/msm/helpers/action-panel.js b/tools/apps/msm/helpers/action-panel.js index 8b38fc7..293d244 100644 --- a/tools/apps/msm/helpers/action-panel.js +++ b/tools/apps/msm/helpers/action-panel.js @@ -43,8 +43,20 @@ const BASE_ACTION_OPTIONS = [ ]; const SAT_ACTION_OPTIONS = [ - { value: 'preview', label: 'Preview' }, - { value: 'publish', label: 'Publish' }, + { + heading: 'Content', + items: [ + { value: 'preview', label: 'Preview' }, + { value: 'publish', label: 'Publish' }, + ], + }, + { + heading: 'Inheritance', + items: [ + { value: 'sync', label: 'Sync from base' }, + { value: 'reset', label: 'Resume inheritance' }, + ], + }, ]; const SYNC_OPTIONS = [ @@ -61,8 +73,8 @@ const ACTION_SCOPE = { }; const ALL_ACTION_ITEMS = [ - ...BASE_ACTION_OPTIONS.flatMap((g) => g.items), - ...SAT_ACTION_OPTIONS, + ...BASE_ACTION_OPTIONS.flatMap((g) => g.items || [g]), + ...SAT_ACTION_OPTIONS.flatMap((g) => g.items || [g]), ]; function getActionLabel(value) { @@ -74,6 +86,7 @@ class MsmActionPanel extends LitElement { org: { type: String }, role: { type: String }, site: { type: String }, + baseSite: { type: String }, pages: { attribute: false }, satellites: { attribute: false }, overrides: { attribute: false }, @@ -294,11 +307,15 @@ class MsmActionPanel extends LitElement { acc[action] = (acc[action] || 0) + 1; return acc; }, {}); - const parts = Object.entries(counts).map( - ([a, c]) => `${getActionLabel(a)} ${c} page${c > 1 ? 's' : ''} (${ACTION_SCOPE[a]} sites only)`, - ); + const parts = Object.entries(counts).map(([a, c]) => { + const label = `${getActionLabel(a)} ${c} page${c > 1 ? 's' : ''}`; + const scope = this._isSatellite ? null : ACTION_SCOPE[a]; + return scope ? `${label} (${scope} sites only)` : label; + }); const satCount = this._selectedSats.size; - return `${parts.join(', ')} across ${satCount} satellite${satCount !== 1 ? 's' : ''}. Satellites that don't match the action scope will be skipped. Continue?`; + const satSuffix = `across ${satCount} satellite${satCount !== 1 ? 's' : ''}`; + const skipNote = this._isSatellite ? '' : ' Satellites that don\'t match the action scope will be skipped.'; + return `${parts.join(', ')} ${satSuffix}.${skipNote} Continue?`; } cancelConfirm() { @@ -333,7 +350,7 @@ class MsmActionPanel extends LitElement { const scope = this._isSatellite ? null : ACTION_SCOPE[action]; return executeBulkAction({ org: this.org, - baseSite: this.site, + baseSite: this.baseSite || this.site, pages, satellites: filteredSats, action, @@ -380,12 +397,11 @@ class MsmActionPanel extends LitElement { await executeBulkAction({ org: this.org, - baseSite: this.site, + baseSite: this.baseSite || this.site, pages: [page], satellites: sats, action, - syncMode: action === 'sync' || action === 'sync-from-base' - ? resolvedSyncMode : undefined, + syncMode: action === 'sync' ? resolvedSyncMode : undefined, scope, overrides: this.overrides, onPageStatus: (key, status, error) => { diff --git a/tools/apps/msm/msm.js b/tools/apps/msm/msm.js index b6e1312..7518d54 100644 --- a/tools/apps/msm/msm.js +++ b/tools/apps/msm/msm.js @@ -219,6 +219,7 @@ class MsmApp extends LitElement { .org=${this._org} .role=${this._role} .site=${this._role === 'satellite' ? this._site : this._currentSite} + .baseSite=${this._baseSite} .pages=${this._selectedPages} .satellites=${this._satellites} .overrides=${this._pageOverrides} From c6f87c9693a2b2543554d6a29dc1de65924d69d5 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Mon, 13 Apr 2026 15:06:00 -0400 Subject: [PATCH 07/26] warning message when the entered site is not available and some UI fixes --- tools/apps/msm/helpers/action-panel.css | 2 +- tools/apps/msm/helpers/action-panel.js | 2 -- tools/apps/msm/helpers/column-browser.css | 2 +- tools/apps/msm/msm.css | 24 +++++++++++++++++++++-- tools/apps/msm/msm.js | 10 +++++++++- 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/tools/apps/msm/helpers/action-panel.css b/tools/apps/msm/helpers/action-panel.css index eb2d77f..9e62767 100644 --- a/tools/apps/msm/helpers/action-panel.css +++ b/tools/apps/msm/helpers/action-panel.css @@ -8,7 +8,6 @@ border: 1px solid var(--s2-gray-200); border-radius: 8px; background: #fff; - overflow: hidden; min-height: 400px; } @@ -19,6 +18,7 @@ padding: 12px 16px; background: var(--s2-gray-75, #f8f8f8); border-bottom: 1px solid var(--s2-gray-200); + border-radius: 8px 8px 0 0; } .panel-title { diff --git a/tools/apps/msm/helpers/action-panel.js b/tools/apps/msm/helpers/action-panel.js index 293d244..40b667a 100644 --- a/tools/apps/msm/helpers/action-panel.js +++ b/tools/apps/msm/helpers/action-panel.js @@ -723,7 +723,6 @@ class MsmActionPanel extends LitElement { const isExpanded = this._expandedRows.has(page.path); const summary = this.getOverrideSummary(page.path); const action = this.getPageAction(page.path); - const hasCustomAction = this._pageActions.has(page.path); return html` @@ -775,7 +774,6 @@ class MsmActionPanel extends LitElement { )} - ${hasCustomAction ? html`custom` : nothing} - ${this._isSatellite ? nothing : html` + ${showOverrides ? html` - `} + ` : nothing} - ${isExpanded ? this.renderExpandedRow(page) : nothing} + ${isExpanded && !this._isUpwardMode ? this.renderExpandedRow(page) : nothing} `; } @@ -810,10 +1297,11 @@ class MsmActionPanel extends LitElement { .filter((o) => this._selectedSats.has(o.site)); const inherited = overrides.filter((o) => !o.hasOverride); const custom = overrides.filter((o) => o.hasOverride); + const colCount = (!this._isUpwardMode && this._hasDownwardActions) ? 5 : 4; return html` - @@ -1305,7 +1310,7 @@ class MsmActionPanel extends LitElement {
    ${inherited.length ? html`
    -
    Inherited
    +
    Following base
      ${inherited.map((sat) => html`
    • @@ -1318,7 +1323,7 @@ class MsmActionPanel extends LitElement { ` : nothing} ${custom.length ? html`
      -
      Custom
      +
      With local copy
        ${custom.map((sat) => html`
      • From e94057a789108b7c3d363a16cf5b540245e55adc Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Fri, 22 May 2026 10:12:36 -0400 Subject: [PATCH 17/26] msm app and plugin updated --- ...5-14-msm-multi-level-inheritance-design.md | 467 +++++++++ package-lock.json | 56 +- tools/apps/msm/README.md | 110 +++ tools/apps/msm/helpers/action-panel.css | 285 +++++- tools/apps/msm/helpers/action-panel.js | 710 +++++++++++--- tools/apps/msm/helpers/api.js | 252 ++++- tools/apps/msm/helpers/column-browser.css | 37 + tools/apps/msm/helpers/column-browser.js | 368 ++++++- tools/apps/msm/msm.css | 108 ++- tools/apps/msm/msm.js | 236 ++++- tools/plugins/msm/README.md | 125 +++ tools/plugins/msm/config.js | 174 ++++ .../msm/img/S2_Icon_AlertTriangle_20_N.svg | 6 + .../msm/img/S2_Icon_CheckmarkCircle_20_N.svg | 5 + .../msm/img/S2_Icon_ChevronRight_20_N.svg | 6 + .../msm/img/S2_Icon_ClockPending_20_N.svg | 12 + tools/plugins/msm/img/S2_Icon_Delete_20_N.svg | 6 + .../msm/img/S2_Icon_ExperienceAdd_20_N.svg | 8 + .../img/S2_Icon_ExperiencePreview_20_N.svg | 9 + .../plugins/msm/img/S2_Icon_Publish_20_N.svg | 4 + tools/plugins/msm/msm.css | 559 +++++++++++ tools/plugins/msm/msm.html | 27 + tools/plugins/msm/msm.js | 895 ++++++++++++++++++ tools/plugins/msm/utils.js | 110 +++ tools/plugins/msm/vendor/se/components.css | 470 +++++++++ tools/plugins/msm/vendor/se/components.js | 555 +++++++++++ 26 files changed, 5344 insertions(+), 256 deletions(-) create mode 100644 docs/superpowers/specs/2026-05-14-msm-multi-level-inheritance-design.md create mode 100644 tools/apps/msm/README.md create mode 100644 tools/plugins/msm/README.md create mode 100644 tools/plugins/msm/config.js create mode 100644 tools/plugins/msm/img/S2_Icon_AlertTriangle_20_N.svg create mode 100644 tools/plugins/msm/img/S2_Icon_CheckmarkCircle_20_N.svg create mode 100644 tools/plugins/msm/img/S2_Icon_ChevronRight_20_N.svg create mode 100644 tools/plugins/msm/img/S2_Icon_ClockPending_20_N.svg create mode 100644 tools/plugins/msm/img/S2_Icon_Delete_20_N.svg create mode 100644 tools/plugins/msm/img/S2_Icon_ExperienceAdd_20_N.svg create mode 100644 tools/plugins/msm/img/S2_Icon_ExperiencePreview_20_N.svg create mode 100644 tools/plugins/msm/img/S2_Icon_Publish_20_N.svg create mode 100644 tools/plugins/msm/msm.css create mode 100644 tools/plugins/msm/msm.html create mode 100644 tools/plugins/msm/msm.js create mode 100644 tools/plugins/msm/utils.js create mode 100644 tools/plugins/msm/vendor/se/components.css create mode 100644 tools/plugins/msm/vendor/se/components.js diff --git a/docs/superpowers/specs/2026-05-14-msm-multi-level-inheritance-design.md b/docs/superpowers/specs/2026-05-14-msm-multi-level-inheritance-design.md new file mode 100644 index 0000000..4b183d8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-msm-multi-level-inheritance-design.md @@ -0,0 +1,467 @@ +# MSM Multi-Level Inheritance — Design Spec + +Date: 2026-05-14 +Affects: `tools/apps/msm/` +Reference inputs: +- `msm-design-notes.md` §2 — multi-level inheritance architecture +- `msm-ui-redesign-spec.md` — Spectrum 2 UI redesign for the action panel + +--- + +## 1. Goal + +Extend the MSM tool app to support **multi-level inheritance**, where a single +site can act as both a satellite (has a parent) and a base (has children) — for +example, `europe-west-en` sitting between `europe-en` and `france-en`. + +The data layer for multi-level (`getSiteRoles`, `walkChain`, `walkSubtree`, +descendant counts, `expandSatellitesWithSubtree`) is already in the working +tree. This spec covers the remaining work to surface that capability in the +authoring UI, and to land the Spectrum 2 redesign that the multi-level UX +depends on. + +## 2. Scope + +### In scope + +- **Dual role classification.** Add a third role `'dual'` for sites that appear + both as `base` and as `satellite` in the org `msm` sheet. +- **Direction switch on dual-role pages.** A Spectrum 2 Switch (M) labeled + "Sync from parent" toggles the action picker between downward (push to + children) and upward (pull from parent). +- **Action picker filtering** by direction — the picker shows only the + optgroups relevant to the active direction. No mixed lists. +- **Upward action set.** Two new picker values, `'sync-from-base'` and + `'resume-inheritance'`, aliased in `executeBulkAction` to the existing + `'sync'` / `'reset'` execution paths with `baseSite = parent` and + `satellites = { [self]: … }`. +- **UI redesign deltas from `msm-ui-redesign-spec.md`** that the multi-level + story depends on: + - Static breadcrumb above the switch (`Inherits from a › b › self`) + - Upward summary block (`Source` / `Target` / `Local override`) + - Action row as a fixed `1fr 1fr` grid with conditional sync-mode picker + - Footer cascade toggle moved next to Apply + - Picker label rebrand: `'preview'` → "Roll out to preview", `'publish'` → + "Roll out to live" (action **values** unchanged) + - Confirm dialog restyled as a yellow caution box using `--s2-yellow-*` + fallbacks + +### Out of scope + +- Worker recursion change in the `da-msm` Cloudflare Worker repo +- Translation / `.da/translate.json` integration +- Custom-path support (`pathmap` sheet, ``) +- Worker-side hardening (KV cache, ETag revalidation, batched HEADs, + cross-org scope check) + +## 3. Architecture + +### 3.1 Roles + +``` +classifySite(site): + roles = getSiteRoles(config, site) + if roles.asBase && roles.asSatellite → role = 'dual' + else if roles.asBase → role = 'base' + else if roles.asSatellite → role = 'satellite' + else → fallback to first base, with warning +``` + +`getSiteRoles` already returns `{asBase, asSatellite}` and is unchanged. + +### 3.2 Data flow into the action panel + +`msm.js` exposes both shapes to `` rather than collapsing to +one: + +| Property | Provided when | Purpose | +| ---------------- | ----------------- | --------------------------------------- | +| `role` | always | `'base' \| 'satellite' \| 'dual'` | +| `parentChain` | satellite, dual | Breadcrumb display | +| `parentBase` | satellite, dual | Upward sync source site | +| `satellites` | base, dual | Direct children (label, descCount) | +| `hasDescendants` | base, dual | Cascade toggle visibility | +| `msmConfig` | always | Subtree expansion for cascade | + +In `handleBrowseSelection`, when navigating via the column browser into a +different site (still in base/dual mode), re-classify so dual-role middle-tier +sites carry their parent context across navigation. + +### 3.3 Direction state + +Direction is **derived**, not stored: + +```js +const UPWARD_VALUES = new Set(['sync-from-base', 'resume-inheritance']); +get _isUpwardMode() { return UPWARD_VALUES.has(this._action); } +get _hasDualRole() { return this.role === 'dual'; } +``` + +The Spectrum 2 switch's `checked` reflects `_isUpwardMode`. `onChange` calls: + +```js +onDirectionToggle(toUpward) { + this.onActionChange(toUpward ? 'sync-from-base' : 'preview'); +} +``` + +`onActionChange` resets `_taskStatuses` and `_pageActions`, same path the +picker `@change` already uses. State stays consistent regardless of which +control the user manipulates. + +**Initial `_action` value depends on role**, set in `connectedCallback`: + +| Role | Initial `_action` | Initial `_isUpwardMode` | +| ----------- | ------------------- | ----------------------- | +| `base` | `'preview'` | false | +| `satellite` | `'sync-from-base'` | true | +| `dual` | `'preview'` | false (default OFF) | + +This replaces the existing unconditional `this._globalAction = 'preview'`. + +### 3.4 Action values and direction mapping + +| Picker value | Direction | Executor case (in `executeBulkAction`) | +| --------------------- | --------- | ------------------------------------------------- | +| `preview` | down | `previewSatellite(org, satSite, …)` | +| `publish` | down | `publishSatellite(org, satSite, …)` | +| `break` | down | `createOverride(org, baseSite, satSite, …)` | +| `sync` | down | `mergeFromBase` or `createOverride` | +| `reset` | down | `deleteOverride(org, satSite, …)` | +| `sync-from-base` | up | aliased to `sync` case (params from upward setup) | +| `resume-inheritance` | up | aliased to `reset` case (params from upward setup)| + +Upward setup: in `apply()` / `executeAll()`, when `_isUpwardMode`, the panel +sets `baseSite = this.parentBase` and `satellites = { [this.site]: {…} }` +before calling `executeBulkAction`. The two new `case 'sync-from-base':` and +`case 'resume-inheritance':` lines fall through to the existing `'sync'` / +`'reset'` handlers — no logic duplication. + +`RECURSIVE_ACTIONS` stays `new Set(['preview', 'publish'])`. Cascade is +downward-only by definition. + +### 3.5 Adaptive layout (per `msm-ui-redesign-spec.md` §4) + +| Page role | Switch state | Breadcrumb | Switch | Picker | Children list | Summary | +| ------------- | --------------- | ---------- | ------ | ------------------ | ------------- | ------- | +| Base only | n/a (down) | hidden | no | Down optgroups | shown | no | +| Satellite only| n/a (up) | shown | no | Up optgroup | not rendered | shown | +| Dual | OFF (down) | shown | yes | Down optgroups | shown | no | +| Dual | ON (up) | shown | yes | Up optgroup | hidden | shown | + +## 4. Component-level changes + +### 4.1 `msm.js` + +- Add `'dual'` branch to `classifySite`. +- Replace single `_baseSite` / `_satellites` with both `_parentBase` / + `_parentChain` (upward context) and `_satellites` / `_hasDescendants` + (downward context). `_baseSite` becomes a derived alias of `_parentBase` + for backward-compat reading where needed. +- Pass `parentBase`, `parentChain`, `role` (now possibly `'dual'`), + `satellites`, `hasDescendants`, `msmConfig` to ``. +- `handleBrowseSelection`: when re-classifying the navigated-into site, + populate both upward and downward fields if it's dual. + +### 4.2 `helpers/action-panel.js` + +**State** — no new state variables. `_action`, `_globalAction`, `_pageActions` +already exist. `_isUpwardMode` and `_hasDualRole` are derived getters. + +**New constants:** + +```js +const DOWNWARD_ACTIONS = [ + { heading: 'Inherited sites', items: [ + { value: 'preview', label: 'Roll out to preview' }, + { value: 'publish', label: 'Roll out to live' }, + { value: 'break', label: 'Cancel inheritance' }, + ]}, + { heading: 'Custom sites', items: [ + { value: 'sync', label: 'Sync to satellite' }, + { value: 'reset', label: 'Resume inheritance' }, + ]}, +]; + +const UPWARD_ACTIONS = [ + { heading: 'From parent', items: [ + { value: 'sync-from-base', label: 'Sync from base' }, + { value: 'resume-inheritance', label: 'Resume inheritance' }, + ]}, +]; + +const UPWARD_VALUES = new Set(['sync-from-base', 'resume-inheritance']); +const RECURSIVE_ACTIONS = new Set(['preview', 'publish']); +``` + +`BASE_ACTION_OPTIONS` and `SAT_ACTION_OPTIONS` are removed; `_actionOptions` +returns `_isUpwardMode ? UPWARD_ACTIONS : DOWNWARD_ACTIONS`. + +**New render helpers:** + +``` +renderBreadcrumb() // satellite/dual: static "Inherits from a › b › self" +renderDirectionSwitch() // dual only: native checkbox styled as S2 Switch +renderUpwardSummary() // satellite/dual+upward: Source/Target/Local override +renderFooter() // cascade toggle (left) + Apply (right) +renderConfirm() // yellow caution box (restyled .alert-dialog) +``` + +`renderActionPicker()` builds optgroups based on `_isUpwardMode`. +`renderChildrenList()` (existing `renderSatelliteGrid`) returns `nothing` when +`_isUpwardMode`. `renderProgress()` and `renderPageRow()` are unchanged. + +**Main `render()`:** + +``` +breadcrumb? +direction switch? +action row (picker + conditional sync-mode) +upward summary? +children list? OR page table? +progress? +footer (cascade + Apply) +confirm? +``` + +`renderSinglePage` and `renderBulk` are simplified to share this skeleton via +a single top-level `renderPanel(mode)` function. The two modes differ only in +which subtree renders inside the body slot (satellite grid vs page table). + +**Apply path:** + +```js +async apply() { + if (this._isUpwardMode) { + return this._applyUpward(); // baseSite = parentBase, sats = {[self]: …} + } + return this._applyDownward(); // existing executeAll() path +} +``` + +`_applyUpward()` reuses `executeBulkAction` with the upward parameter setup, +hitting the aliased `case 'sync-from-base':` / `case 'resume-inheritance':` +branches in `api.js`. + +### 4.3 `helpers/api.js` + +Add two `case` aliases in the `executeBulkAction` switch: + +```js +case 'sync': +case 'sync-from-base': + result = syncMode === 'merge' + ? await mergeFromBase(org, baseSite, satSite, pagePath, ext) + : await createOverride(org, baseSite, satSite, pagePath, ext); + break; +case 'reset': +case 'resume-inheritance': { + // existing reset block, unchanged +} +``` + +No other API changes. `getSiteRoles`, `walkChain`, `walkSubtree`, +`getDescendantCount`, `expandSatellitesWithSubtree` — all unchanged. + +### 4.4 `helpers/action-panel.css` + +**Add:** + +- `.crumb-row` — single static horizontal row, no card, no click affordance +- `.direction-switch` — S2 Switch (M) lookalike: 26×14 pill track, 10px white + knob, `gray-300` off-track, `blue-700` on-track, 14px label "Sync from + parent" to the right +- `.upward-summary` — flex column of `
      • + ${CHECK_ICON} + ${opt.label} +
      • + `; + }; + return html`
        ${label ? html`` : nothing} @@ -494,22 +805,10 @@ class MsmActionPanel extends LitElement { if (group.items) { return html`
      • ${group.heading}
      • - ${group.items.map((opt) => html` -
      • this.selectPickerOption(name, opt.value, setter)}> - ${CHECK_ICON} - ${opt.label} -
      • - `)} + ${group.items.map(renderOption)} `; } - return html` -
      • this.selectPickerOption(name, group.value, setter)}> - ${CHECK_ICON} - ${group.label} -
      • - `; + return renderOption(group); })}
      ` : nothing} @@ -518,6 +817,141 @@ class MsmActionPanel extends LitElement { `; } + // Returns the per-row action options for `page`. Because the column-browser + // enforces a single-category selection, the per-row options here mirror the + // category that the panel is already in. We still resolve per-page so each + // row reflects its own data if the mutex is ever bypassed. + _actionOptionsForPage(page) { + if (!page || !this._isUpwardMode) return this._actionOptions; + return this._upwardActionsForCategory(this._categorizePage(page)); + } + + // ────────────────────────────────────── + // Render: Breadcrumb (multi-level inheritance chain) + // ────────────────────────────────────── + + renderBreadcrumb() { + if (!this._hasUpwardActions) return nothing; + const nodes = [ + ...(this.parentChain || []), + { site: this.site, label: this.site, current: true }, + ]; + if (nodes.length <= 1) return nothing; + return html` +
      + Inherits from + ${nodes.map((node, idx) => html` + ${idx > 0 ? html`` : nothing} + ${node.label} + `)} +
      + `; + } + + // ────────────────────────────────────── + // Render: Direction switch (Spectrum 2 Switch) + // ────────────────────────────────────── + + renderDirectionSwitch() { + if (!this._hasDualRole) return nothing; + const checked = this._isUpwardMode; + return html` + + `; + } + + onDirectionToggle(toUpward) { + this._globalAction = toUpward ? 'sync-from-base' : 'preview'; + this._pageActions = new Map(); + this._resetExecution(); + // Dual-role: 'sync-from-base' isn't valid for inherited selections, so + // resolve to the first allowed upward action for the current category. + if (toUpward) this._validateActionForCategory(); + } + + // ────────────────────────────────────── + // Render: Upward summary (Source / Target / Local override) + // ────────────────────────────────────── + + renderUpwardSummary() { + if (!this._isUpwardMode) return nothing; + if (!this._hasUpwardActions) return nothing; + + const source = this.parentBase || '\u2014'; + const target = this.site || '\u2014'; + + if (this.isSinglePage) { + const page = this.pages[0]; + const pagePath = page?.path || ''; + const overrides = this.getPageOverrides(pagePath); + const selfEntry = overrides.find((o) => o.site === this.site); + const hasOverride = selfEntry?.hasOverride === true; + const inheritedFrom = selfEntry?.inheritedFrom || null; + const effectiveSource = selfEntry?.sourceSite || this.parentBase || source; + let overrideText = 'No'; + if (hasOverride) overrideText = 'Yes'; + else if (inheritedFrom) overrideText = `No \u2014 inherited from ${inheritedFrom}`; + return html` +
      +
      Source${effectiveSource}${pagePath}
      +
      Target${target}${pagePath}
      +
      Local override${overrideText}
      +
      + `; + } + + const overriddenCount = this.pages.reduce((acc, p) => { + const ov = this.getPageOverrides(p.path).find((o) => o.site === this.site); + return acc + (ov?.hasOverride ? 1 : 0); + }, 0); + return html` +
      +
      Source${source}
      +
      Target${target}
      +
      Pages with local override${overriddenCount} of ${this.pages.length}
      +
      + `; + } + + // ────────────────────────────────────── + // Render: Footer (cascade toggle + Apply) + // ────────────────────────────────────── + + renderFooter(applyOnClick, applyDisabled) { + return html` +
      + ${this._showDescendantsToggle ? html` + + ` : html``} + + Apply + +
      + `; + } + // ────────────────────────────────────── // Render: Confirm dialog // ────────────────────────────────────── @@ -525,8 +959,15 @@ class MsmActionPanel extends LitElement { renderConfirm() { if (!this._confirmAction) return nothing; return html` -
      -

      Confirm action

      +
      +

      + + Confirm action +

      ${this._confirmAction.message}

      @@ -543,8 +984,11 @@ class MsmActionPanel extends LitElement { renderSinglePage() { const page = this.pages[0]; const overrides = this.getPageOverrides(page.path); - const inherited = overrides.filter((o) => !o.hasOverride); - const custom = overrides.filter((o) => o.hasOverride); + // Satellite grids only show child sites; exclude the self-entry that the + // satellite/dual upward path injects into the same overrides map. + const satEntries = overrides.filter((o) => o.site !== this.site); + const inherited = satEntries.filter((o) => !o.hasOverride); + const custom = satEntries.filter((o) => o.hasOverride); return html`
      @@ -552,39 +996,60 @@ class MsmActionPanel extends LitElement {

      ${page.name}

      + ${this.renderBreadcrumb()} + ${this.renderDirectionSwitch()} + ${this._isLocalUpwardEmpty ? this.renderLocalUpwardEmpty() : html`
      ${this.renderPicker( 'action', 'Action', this._globalAction, - this._actionOptions, + this._isUpwardMode ? this._actionOptionsForPage(page) : this._actionOptions, (v) => { this._globalAction = v; this._resetExecution(); }, )} - ${this._globalAction === 'sync' ? this.renderPicker( + ${this._shouldShowSyncPicker(this._globalAction) ? this.renderPicker( 'syncMode', 'Sync mode', this._globalSyncMode, SYNC_OPTIONS, (v) => { this._globalSyncMode = v; }, - ) : nothing} + ) : html`
      `}
      - ${this._isSatellite ? nothing : this.renderSatelliteGrid(inherited, custom)} + ${this.renderUpwardSummary()} + `} + ${this._isUpwardMode ? nothing : this.renderSatelliteGrid(inherited, custom)} ${this.renderConfirm()} ${this._executing ? this.renderProgress() : nothing} - -
      - this.executeSinglePage()} - ?disabled=${this._busy || this._singleSelectedSats.size === 0 - || !this.hasApplicableSats(this.pages[0]?.path, this._globalAction)}> - Apply - -
      + ${this.renderFooter( + () => this.executeSinglePage(), + this._busy || this._isLocalUpwardEmpty || !this._canApplySingle(page), + )}
      `; } + _canApplySingle(page) { + if (this._isUpwardMode) { + // Upward: target is self. Some actions only apply to specific + // categories; verify the row matches before allowing Apply. + // For bulk per-row Apply, use the row's action; otherwise the panel-wide action. + const action = (!this.isSinglePage && page) + ? this.getPageAction(page.path) + : this._globalAction; + if (action === 'resume-inheritance') { + const ov = this.getPageOverrides(page.path).find((o) => o.site === this.site); + return ov?.hasOverride === true; + } + if (action === 'cancel-inheritance') { + return this._categorizePage(page) === 'inherited'; + } + return true; + } + return this._singleSelectedSats.size > 0 + && this.hasApplicableSats(page?.path, this._globalAction); + } + renderSatelliteGrid(inherited, custom) { const scope = ACTION_SCOPE[this._globalAction]; @@ -592,7 +1057,7 @@ class MsmActionPanel extends LitElement {
      ${inherited.length ? html`
      -
      Inherited
      +
      Following base
        ${inherited.map((sat) => this.renderSatRow(sat, scope !== 'inherited'))}
      @@ -600,7 +1065,7 @@ class MsmActionPanel extends LitElement { ` : nothing} ${custom.length ? html`
      -
      Custom
      +
      With local copy
        ${custom.map((sat) => this.renderSatRow(sat, scope !== 'custom', true))}
      @@ -612,6 +1077,8 @@ class MsmActionPanel extends LitElement { renderSatRow(sat, outOfScope, showEdit = false) { const statusEntry = this._taskStatuses.get(`${this.pages[0]?.path}:${sat.site}`); + const info = this.satellites?.[sat.site] || {}; + const descCount = info.descendantCount || 0; return html`
    • ${statusEntry ? this.statusIcon(statusEntry.status) : nothing} ${showEdit ? html` - + ${EDIT_ICON} ` : nothing} @@ -643,23 +1113,35 @@ class MsmActionPanel extends LitElement { ${this.site}
    - ${this._isSatellite ? nothing : this.renderSatelliteFilter()} - ${this.renderGlobalActionBar()} - ${this._executing ? this.renderProgress() : this.renderPageTable()} + ${this.renderBreadcrumb()} + ${this.renderDirectionSwitch()} + ${this._isUpwardMode ? nothing : this.renderSatelliteFilter()} + ${this._isLocalUpwardEmpty ? this.renderLocalUpwardEmpty() : html` + ${this.renderGlobalActionBar()} + ${this.renderUpwardSummary()} + ${this._executing ? this.renderProgress() : this.renderPageTable()} + `} ${this.renderConfirm()} -
    - this.executeAll()} - ?disabled=${this._busy || this._selectedSats.size === 0 - || this._includedPages.size === 0 || !this._hasAnyApplicablePages}> - Apply All - -
    + ${this.renderFooter( + () => this.executeAll(), + this._busy + || this._isLocalUpwardEmpty + || this._includedPages.size === 0 + || !this._canApplyBulk(), + )}
    `; } + _canApplyBulk() { + if (this._isUpwardMode) { + // Upward bulk: each page targets self; allowed if at least one page is applicable. + return this._activePages.some((p) => this._canApplySingle(p)); + } + return this._selectedSats.size > 0 && this._hasAnyApplicablePages; + } + renderSatelliteFilter() { const sats = Object.entries(this.satellites || {}); if (sats.length <= 1) return nothing; @@ -667,14 +1149,18 @@ class MsmActionPanel extends LitElement { return html`
    Satellites - ${sats.map(([satSite, info]) => html` - - `)} + ${sats.map(([satSite, info]) => { + const dc = info.descendantCount || 0; + return html` + + `; + })}
    `; } @@ -694,13 +1180,13 @@ class MsmActionPanel extends LitElement { this._resetExecution(); }, )} - ${this._globalAction === 'sync' ? this.renderPicker( + ${this._shouldShowSyncPicker(this._globalAction) ? this.renderPicker( 'globalSyncMode', 'Sync mode', this._globalSyncMode, SYNC_OPTIONS, (v) => { this._globalSyncMode = v; }, - ) : nothing} + ) : html`
    `} `; } @@ -708,12 +1194,13 @@ class MsmActionPanel extends LitElement { renderPageTable() { const allChecked = this._includedPages.size === this.pages.length; const someChecked = this._includedPages.size > 0 && !allChecked; + const showOverrides = !this._isUpwardMode && this._hasDownwardActions; return html`
    + this.toggleAllPages()} /> + PageOverridesAction
    - this.toggleRow(page.path)}> - - - ${page.name} + this.togglePageInclude(page.path)} /> + +
    this.toggleRow(page.path)}> + ${page.name} + ${this._isSatellite ? nothing : html` + + + + `} +
    +
    @@ -642,19 +728,30 @@ class MsmActionPanel extends LitElement { - ${this.renderPicker( +
    + ${this.renderPicker( `page-${page.path}`, '', action, this._actionOptions, (v) => this.setPageAction(page.path, v), )} +
    + ${this.renderPicker( + `page-sync-${page.path}`, + '', + this.getPageSyncMode(page.path), + SYNC_OPTIONS, + (v) => this.setPageSyncMode(page.path, v), + )} +
    +
    ${hasCustomAction ? html`custom` : nothing}
    - + this.executeRow(page)} + ?disabled=${this._busy}>Apply
    +
    ${inherited.length ? html`
    diff --git a/tools/apps/msm/helpers/api.js b/tools/apps/msm/helpers/api.js index 5d6f560..d3447e7 100644 --- a/tools/apps/msm/helpers/api.js +++ b/tools/apps/msm/helpers/api.js @@ -5,21 +5,16 @@ const AEM_ADMIN = 'https://admin.hlx.page'; const MAX_CONCURRENT = 5; let daFetchFn; -let fetchDaConfigsFn; -async function ensureDeps() { +async function ensureDaFetch() { if (!daFetchFn) { - const utils = await import('https://da.live/nx/public/utils/daFetch.js'); - daFetchFn = utils.daFetch; - } - if (!fetchDaConfigsFn) { - const utils = await import('https://da.live/nx/public/utils/utils.js'); - fetchDaConfigsFn = utils.fetchDaConfigs; + const { daFetch: fn } = await import('https://da.live/nx/utils/daFetch.js'); + daFetchFn = fn; } } async function daFetch(url, opts = {}) { - await ensureDeps(); + await ensureDaFetch(); return daFetchFn(url, opts); } @@ -51,10 +46,17 @@ async function runWithConcurrency(tasks, limit = MAX_CONCURRENT) { const configCache = {}; +async function fetchOrgConfig(org) { + if (configCache[org]) return configCache[org]; + const resp = await daFetch(`${DA_ORIGIN}/config/${org}/`); + if (!resp.ok) return null; + const json = await resp.json(); + configCache[org] = json; + return json; +} + async function fetchOrgMsmRows(org) { - await ensureDeps(); - const configs = await fetchDaConfigsFn({ org }); - const orgConfig = configs?.[0]; + const orgConfig = await fetchOrgConfig(org); return orgConfig?.msm?.data || []; } @@ -104,12 +106,19 @@ export async function listFolder(org, site, path = '/') { const resp = await daFetch(url); if (!resp.ok) return []; const items = await resp.json(); - return items.map((item) => ({ - name: item.name, - path: item.path || `${cleanPath === '/' ? '' : cleanPath}/${item.name}`, - ext: item.ext || (item.name.includes('.') ? item.name.split('.').pop() : null), - isFolder: !item.ext && !item.name.includes('.'), - })); + const prefix = `/${org}/${site}`; + return items.map((item) => { + let itemPath = item.path || `${cleanPath === '/' ? '' : cleanPath}/${item.name}`; + if (itemPath.startsWith(prefix)) { + itemPath = itemPath.substring(prefix.length) || '/'; + } + return { + name: item.name, + path: itemPath, + ext: item.ext || (item.name.includes('.') ? item.name.split('.').pop() : null), + isFolder: !item.ext && !item.name.includes('.'), + }; + }); } // ────────────────────────────────────────────── diff --git a/tools/apps/msm/helpers/column-browser.css b/tools/apps/msm/helpers/column-browser.css index ed99dc4..caab7d9 100644 --- a/tools/apps/msm/helpers/column-browser.css +++ b/tools/apps/msm/helpers/column-browser.css @@ -8,16 +8,35 @@ display: flex; border: 1px solid var(--s2-gray-200); border-radius: 8px; - overflow: hidden; + overflow-x: auto; + overflow-y: hidden; min-height: 320px; max-height: 480px; - background: var(--s2-gray-50, #fafafa); + background: + linear-gradient(to bottom, + var(--s2-gray-75, #f8f8f8) 32px, + var(--s2-gray-200) 32px, + var(--s2-gray-200) 33px, + var(--s2-gray-50, #fafafa) 33px); +} + +.browser::-webkit-scrollbar { height: 5px; } +.browser::-webkit-scrollbar-track { background: transparent; } + +.browser::-webkit-scrollbar-thumb { + background: var(--s2-gray-300); + border-radius: 3px; +} + +.browser::-webkit-scrollbar-thumb:hover { + background: var(--s2-gray-500); } /* ===== Column ===== */ .column { flex: 0 0 220px; + min-width: 220px; display: flex; flex-direction: column; border-right: 1px solid var(--s2-gray-200); @@ -26,7 +45,6 @@ .column:last-child { border-right: none; - flex: 1 1 220px; } .column-header { @@ -106,52 +124,47 @@ outline-offset: 2px; } -/* ===== Checkbox ===== */ +/* ===== Checkbox (Spectrum 2) ===== */ .item input[type="checkbox"] { appearance: none; width: 14px; height: 14px; margin: 0; - border: 2px solid var(--s2-gray-600, #717171); + border: 2px solid var(--s2-gray-600); border-radius: 2px; cursor: pointer; flex-shrink: 0; position: relative; - transition: border 0.13s ease-in-out; + background: transparent; + transition: background-color 0.13s, border-color 0.13s; } .item input[type="checkbox"]:checked { + background: var(--s2-gray-800); border-color: var(--s2-gray-800); - border-width: 7px; - background: var(--s2-gray-50, #f8f8f8); } .item input[type="checkbox"]:checked::after { content: ''; position: absolute; inset: 0; - top: -2px; - left: -3px; + margin: auto; + margin-top: -1px; width: 4px; height: 8px; - margin: auto; - border: solid var(--s2-gray-50, #f8f8f8); + border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); } -.item input[type="checkbox"]:focus-visible { - outline: 2px solid var(--s2-blue-900); - outline-offset: 2px; -} - .item input[type="checkbox"]:hover:not(:disabled) { - border-color: var(--s2-gray-700); + border-color: var(--s2-gray-800); } -.item input[type="checkbox"]:checked:hover:not(:disabled) { - border-color: var(--s2-gray-800); +.item input[type="checkbox"]:focus-visible { + outline: 2px solid var(--s2-blue-900); + outline-offset: 2px; } /* ===== Item label and icons ===== */ diff --git a/tools/apps/msm/helpers/column-browser.js b/tools/apps/msm/helpers/column-browser.js index 2b588ed..8e8b1bf 100644 --- a/tools/apps/msm/helpers/column-browser.js +++ b/tools/apps/msm/helpers/column-browser.js @@ -3,10 +3,14 @@ import { LitElement, html, nothing } from 'da-lit'; import { listFolder } from './api.js'; const NX = 'https://da.live/nx'; +let sl; let sheet; try { const { default: getStyle } = await import(`${NX}/utils/styles.js`); - sheet = await getStyle(import.meta.url); + [sl, sheet] = await Promise.all([ + getStyle(`${NX}/public/sl/styles.css`), + getStyle(import.meta.url), + ]); } catch (e) { console.warn('Failed to load column-browser styles:', e); } @@ -31,16 +35,17 @@ class MsmColumnBrowser extends LitElement { connectedCallback() { super.connectedCallback(); - if (sheet) this.shadowRoot.adoptedStyleSheets = [sheet]; + this.shadowRoot.adoptedStyleSheets = [sl, sheet].filter(Boolean); this._columns = []; this._checked = new Set(); this._activeColumnIdx = 0; this._loadingColumn = -1; this._focusedItemIdx = -1; + this._folderCache = new Map(); this._handleKeydown = this._onKeydown.bind(this); - if (this.role === 'satellite' && this.site) { - this.initSatelliteRoot(); + if (this.site) { + this.initSiteRoot(); } else { this.initSitesColumn(); } @@ -110,6 +115,15 @@ class MsmColumnBrowser extends LitElement { }); } + scrollToActiveColumn() { + this.updateComplete.then(() => { + const browser = this.shadowRoot.querySelector('.browser'); + if (browser) { + browser.scrollTo({ left: browser.scrollWidth, behavior: 'smooth' }); + } + }); + } + toggleCheck(item) { const next = new Set(this._checked); const key = `${item.site || ''}:${item.path}`; @@ -132,7 +146,7 @@ class MsmColumnBrowser extends LitElement { this._columns = [{ header: 'Sites', items, selectedPath: null }]; } - async initSatelliteRoot() { + async initSiteRoot() { this._loadingColumn = 0; this._columns = [{ header: this.site, items: [], selectedPath: null, @@ -146,7 +160,7 @@ class MsmColumnBrowser extends LitElement { selectedPath: null, }]; } catch (e) { - console.error('Failed to load satellite root:', e); + console.error('Failed to load site root:', e); this._columns = [{ header: this.site, items: [], selectedPath: null }]; } this._loadingColumn = -1; @@ -184,12 +198,14 @@ class MsmColumnBrowser extends LitElement { ]; } this._loadingColumn = -1; + this.scrollToActiveColumn(); this.clearChecksAfterColumn(colIdx); this.emitSelection(site); } - findSite(colIdx) { + findSite(colIdx, item) { + if (item?.site) return item.site; if (this.role === 'satellite' && this.site) return this.site; const searchCols = this._columns.slice(0, colIdx + 1).reverse(); @@ -207,8 +223,8 @@ class MsmColumnBrowser extends LitElement { const firstSiteCol = this._columns[0]; if (firstSiteCol?.selectedPath) { - const item = firstSiteCol.items.find((i) => i.path === firstSiteCol.selectedPath); - return item?.site; + const firstSel = firstSiteCol.items.find((i) => i.path === firstSiteCol.selectedPath); + return firstSel?.site; } return null; } @@ -227,6 +243,8 @@ class MsmColumnBrowser extends LitElement { if (e?.target?.type === 'checkbox') return; if (item.isFolder || item.isSite) { this.navigateToFolder(colIdx, item); + } else { + this.toggleCheck(item); } } @@ -254,16 +272,53 @@ class MsmColumnBrowser extends LitElement { this._checked = new Set([...this._checked].filter((key) => validPaths.has(key))); } - emitSelection(site) { - const lastCol = this._columns[this._columns.length - 1]; - if (!lastCol) return; + async emitSelection(site) { + const checkedPages = []; + const checkedFolders = []; + + this._columns.forEach((col) => { + col.items.forEach((item) => { + const key = `${item.site || site || ''}:${item.path}`; + if (!this._checked.has(key)) return; + if (item.isFolder && !item.isSite) { + checkedFolders.push(item); + } else if (!item.isSite) { + checkedPages.push(item); + } + }); + }); - const selectedItems = lastCol.items.filter((item) => { - const key = `${item.site || site || ''}:${item.path}`; - return this._checked.has(key); + const folderPages = await Promise.all( + checkedFolders.map(async (folder) => { + const folderSite = folder.site || site; + const cacheKey = `${this.org}/${folderSite}${folder.path}`; + if (!this._folderCache.has(cacheKey)) { + try { + const items = await listFolder(this.org, folderSite, folder.path); + this._folderCache.set( + cacheKey, + items.filter((i) => i.ext === 'html').map((i) => ({ ...i, site: folderSite })), + ); + } catch (e) { + console.error('Failed to resolve folder pages:', e); + this._folderCache.set(cacheKey, []); + } + } + return this._folderCache.get(cacheKey); + }), + ); + + const allItems = [...checkedPages, ...folderPages.flat()]; + const seen = new Set(); + const selectedItems = allItems.filter((item) => { + const key = `${item.site || ''}:${item.path}`; + if (seen.has(key)) return false; + seen.add(key); + return true; }); - const currentPath = lastCol.header || ''; + const lastCol = this._columns[this._columns.length - 1]; + const currentPath = lastCol?.header || ''; this.dispatchEvent(new CustomEvent('browse-selection', { detail: { selectedItems, currentPath, site: site || this.getCurrentSite() }, bubbles: true, @@ -283,7 +338,7 @@ class MsmColumnBrowser extends LitElement { } showCheckbox(item) { - return item.ext === 'html' || item.isFolder; + return !item.isSite && (item.ext === 'html' || item.isFolder); } renderItem(colIdx, item, itemIdx) { diff --git a/tools/apps/msm/msm.css b/tools/apps/msm/msm.css index 7c89a64..e3f88c5 100644 --- a/tools/apps/msm/msm.css +++ b/tools/apps/msm/msm.css @@ -1,146 +1,47 @@ :host { display: block; - max-width: 1200px; - 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); color: var(--s2-gray-800); } -/* ===== Header ===== */ +/* ===== Toolbar ===== */ -.msm-header { - display: flex; - align-items: center; - justify-content: space-between; +.msm-toolbar { margin-bottom: var(--spacing-400); - padding-bottom: var(--spacing-300); - border-bottom: 2px solid var(--s2-gray-200); + padding-bottom: var(--spacing-400); } -.msm-header h1 { - margin: 0; - font-size: var(--s2-heading-size-700); +.msm-toolbar h1 { + font-size: var(--type-heading-xxl-size); + line-height: var(--spectrum-line-height-100); + margin: 0 0 var(--spacing-400); font-weight: 700; - color: var(--s2-gray-800); } -.msm-header-meta { +.msm-toolbar-form { display: flex; - align-items: center; - gap: var(--spacing-200); + gap: 16px; + align-items: flex-start; } -.msm-header .site-label { - font-size: var(--s2-font-size-200); - color: var(--s2-gray-600); +.msm-toolbar-form sl-input { + flex: 1; } -.msm-header .role-badge { - font-size: var(--s2-font-size-75, 11px); +.role-badge { + display: inline-block; + margin-top: 12px; + font-size: var(--s2-body-xxs-size, 11px); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 8px; - border-radius: 4px; - background: var(--s2-blue-100, #deebff); - color: var(--s2-blue-900, #0747a6); -} - -/* ===== Init ===== */ - -.msm-init { - display: flex; - align-items: center; - justify-content: center; - min-height: 60vh; -} - -.msm-init-card { - width: 100%; - max-width: 420px; - padding: var(--spacing-600); - border: 1px solid var(--s2-gray-200); - border-radius: 8px; - background: var(--s2-gray-25, #fff); -} - -.msm-init-card h1 { - margin: 0 0 var(--spacing-100); - font-size: var(--s2-heading-size-700); - font-weight: 700; - color: var(--s2-gray-800); -} - -.msm-init-card p { - margin: 0 0 var(--spacing-400); - font-size: var(--s2-font-size-200); - color: var(--s2-gray-600); -} - -.msm-init-form { - display: flex; - flex-direction: column; - gap: var(--spacing-300); -} - -.msm-init-fields { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--spacing-300); -} - -.msm-init-hint { - margin: 0; - font-size: var(--s2-font-size-75, 11px); - color: var(--s2-gray-500); - line-height: 1.4; -} - -.msm-init-field { - display: flex; - flex-direction: column; - gap: var(--spacing-100); -} - -.msm-init-field .optional { - font-weight: 400; - color: var(--s2-gray-500); -} - -.msm-init-field label { - font-size: var(--s2-font-size-100); - font-weight: 600; - color: var(--s2-gray-700); -} - -.msm-init-field input { - padding: var(--spacing-200) var(--spacing-300); - border: 1px solid var(--s2-gray-300); - border-radius: 4px; - font-size: var(--s2-font-size-200); - color: var(--s2-gray-800); - background: var(--s2-gray-50, #fafafa); - outline: none; - transition: border-color 0.15s; -} - -.msm-init-field input:focus { - border-color: var(--s2-blue-800, #1473e6); - box-shadow: 0 0 0 1px var(--s2-blue-800, #1473e6); -} - -.msm-init-field input::placeholder { - color: var(--s2-gray-500); -} - -.msm-init-error { - padding: var(--spacing-200) var(--spacing-300); - border-radius: 4px; - background: var(--s2-red-100, #ffe0e0); - color: var(--s2-red-900, #a10000); - font-size: var(--s2-font-size-100); + border-radius: var(--s2-radius-75, 4px); + background: var(--s2-blue-100); + color: var(--s2-blue-900); } /* ===== Loading / Empty ===== */ @@ -187,3 +88,24 @@ flex-direction: column; gap: var(--spacing-400); } + +/* ===== Responsive ===== */ + +@media (width <= 600px) { + :host { + margin: 16px; + } + + .msm-toolbar h1 { + font-size: 20px; + } + + .msm-toolbar-form { + flex-direction: column; + gap: 8px; + } + + .msm-toolbar-form sl-input { + width: 100%; + } +} diff --git a/tools/apps/msm/msm.html b/tools/apps/msm/msm.html index 845cfde..6f429fe 100644 --- a/tools/apps/msm/msm.html +++ b/tools/apps/msm/msm.html @@ -8,7 +8,7 @@ { "imports": { "da-lit": "/tools/deps/lit/dist/index.js" } } - + diff --git a/tools/apps/msm/msm.js b/tools/apps/msm/msm.js index 09de724..6123ce3 100644 --- a/tools/apps/msm/msm.js +++ b/tools/apps/msm/msm.js @@ -2,6 +2,7 @@ import DA_SDK from 'https://da.live/nx/utils/sdk.js'; import { LitElement, html, nothing } from 'da-lit'; import { fetchMsmConfig, checkPageOverrides } from './helpers/api.js'; +import 'https://da.live/nx/public/sl/components.js'; import './helpers/column-browser.js'; import './helpers/action-panel.js'; @@ -46,28 +47,18 @@ class MsmApp extends LitElement { this._pageOverrides = new Map(); this._initError = ''; this._role = 'base'; - - const org = this.context?.org; - const site = this.context?.repo; - if (org) { - this._org = org; - this._site = site || ''; - this._state = 'loading'; - this.loadConfig(org); - } else { - this._state = 'init'; - } + this._state = 'init'; } handleOrgSubmit(e) { e.preventDefault(); - const orgInput = this.shadowRoot.querySelector('#org-input'); - const siteInput = this.shadowRoot.querySelector('#site-input'); - const org = (orgInput?.value || '').trim().replace(/^\/+/, ''); - const site = (siteInput?.value || '').trim().replace(/^\/+/, ''); - if (!org) return; + const input = this.shadowRoot.querySelector('#path-input'); + const raw = (input?.value || '').trim(); + const parts = raw.replace(/^\/+/, '').split('/').filter(Boolean); + if (!parts.length) return; + const [org, site] = parts; this._org = org; - this._site = site; + this._site = site || ''; this._initError = ''; this._state = 'loading'; this.loadConfig(org); @@ -76,6 +67,7 @@ class MsmApp extends LitElement { classifySite(config) { if (!this._site) { this._role = 'base'; + this._baseSite = ''; return; } @@ -91,7 +83,16 @@ class MsmApp extends LitElement { return; } + const isBase = config.baseSites.find((bs) => bs.site === this._site); + if (isBase) { + this._role = 'base'; + this._baseSite = this._site; + this._satellites = isBase.satellites; + return; + } + this._role = 'base'; + this._baseSite = ''; } async loadConfig(org) { @@ -155,94 +156,58 @@ class MsmApp extends LitElement { return this._selectedPages.length === 1; } - renderHeader() { - const org = this._org || ''; - const site = this._role === 'satellite' ? this._site : (this._currentSite || ''); - return html` -
    -

    MSM Actions

    -
    - ${site ? html`${org} / ${site}` : nothing} - ${this._role === 'satellite' ? html`Satellite` : nothing} -
    -
    - `; + get _inputValue() { + if (!this._org) return ''; + return this._site ? `/${this._org}/${this._site}` : `/${this._org}`; } - renderLoading() { + renderToolbar() { return html` -
    -
    - Loading MSM configuration\u2026 +
    +

    MSM Actions

    +
    + + Load +
    + ${this._role === 'satellite' ? html`Satellite` : nothing}
    `; } - renderInit() { - return html` -
    -
    -

    MSM Actions

    -

    Enter your organization and, optionally, a site name.

    -
    -
    -
    - - -
    -
    - - -
    -
    -

    - Leave site blank to manage all base sites. - Enter a satellite site name to preview and publish your content. -

    - ${this._initError ? html` - - ` : nothing} - -
    -
    -
    - `; - } + renderContent() { + if (this._state === 'init') return nothing; - renderEmpty() { - return html` -
    -

    No MSM base sites configured for ${this._org}.

    - -
    - `; - } + if (this._state === 'loading') { + return html` +
    +
    + Loading MSM configuration\u2026 +
    + `; + } - render() { - if (this._state === 'init') return this.renderInit(); - if (this._state === 'loading') return this.renderLoading(); - if (this._state === 'no-config') return this.renderEmpty(); + if (this._state === 'no-config') { + return html` +
    +

    No MSM base sites configured for ${this._org}.

    +
    + `; + } return html` - ${this.renderHeader()}
    @@ -260,6 +225,13 @@ class MsmApp extends LitElement {
    `; } + + render() { + return html` + ${this.renderToolbar()} + ${this.renderContent()} + `; + } } customElements.define('msm-app', MsmApp); From 0a9f42619f8f9eec08d1a48241a9b6b6b0562034 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Fri, 10 Apr 2026 17:53:55 -0400 Subject: [PATCH 03/26] mobile fixes --- tools/apps/msm/helpers/action-panel.css | 80 +++++++++++++++++------ tools/apps/msm/helpers/action-panel.js | 10 +-- tools/apps/msm/helpers/column-browser.css | 14 ++-- tools/apps/msm/helpers/column-browser.js | 1 + 4 files changed, 73 insertions(+), 32 deletions(-) diff --git a/tools/apps/msm/helpers/action-panel.css b/tools/apps/msm/helpers/action-panel.css index f04f607..626cd37 100644 --- a/tools/apps/msm/helpers/action-panel.css +++ b/tools/apps/msm/helpers/action-panel.css @@ -647,6 +647,11 @@ /* Page table → card layout */ .page-table { display: block; + table-layout: auto; + } + + .page-table colgroup { + display: none; } .page-table thead { @@ -660,10 +665,10 @@ } .page-table tr { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: auto 1fr auto; + gap: 4px 10px; align-items: center; - gap: 6px 10px; padding: 10px 12px; border: 1px solid var(--s2-gray-200); border-radius: 8px; @@ -673,44 +678,65 @@ display: block; padding: 0; border: none; + height: auto; } - .page-table tr:hover td { - background: transparent; + /* Row 1: checkbox | name + chevron */ + .cell-check { + grid-column: 1; + grid-row: 1; + align-self: start; + padding-top: 4px; } - .page-table tr:hover { - background: var(--s2-gray-50); + .cell-name { + grid-column: 2 / -1; + grid-row: 1; + min-width: 0; } .page-name-cell { - flex: 1; min-width: 0; } - .page-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + /* Row 2: overrides beside name (indented under name) */ + .cell-overrides { + grid-column: 2 / -1; + grid-row: 2; + } + + /* Row 3: pickers + apply */ + .cell-action { + grid-column: 1 / 3; + grid-row: 3; + } + + .cell-apply { + grid-column: 3; + grid-row: 3; + align-self: end; + } + + .page-table tr:hover td { + background: transparent; + } + + .page-table tr:hover { + background: var(--s2-gray-50); } .page-action-pickers { flex-flow: row wrap; gap: 6px; - width: 100%; - } - - .page-action-pickers .form-row { - flex: 1; - min-width: 0; } .page-action-pickers .picker-trigger { - width: 100%; + min-width: 0; + flex: 1 1 140px; } - .row-actions { - margin-left: auto; + .sync-picker-slot.hidden { + display: none; } .expand-row td { /* stylelint-disable-line no-descending-specificity */ @@ -718,6 +744,18 @@ width: 100%; } + .expand-row { + display: block; + border: none; + border-top: 1px dashed var(--s2-gray-200); + padding: 0; + margin-top: -8px; + border-radius: 0 0 8px 8px; + border-left: 1px solid var(--s2-gray-200); + border-right: 1px solid var(--s2-gray-200); + border-bottom: 1px solid var(--s2-gray-200); + } + .form-actions { position: sticky; bottom: 0; diff --git a/tools/apps/msm/helpers/action-panel.js b/tools/apps/msm/helpers/action-panel.js index 2fe33a4..f681032 100644 --- a/tools/apps/msm/helpers/action-panel.js +++ b/tools/apps/msm/helpers/action-panel.js @@ -698,12 +698,12 @@ class MsmActionPanel extends LitElement { return html`
    + this.togglePageInclude(page.path)} /> +
    this.toggleRow(page.path)}> ${page.name} @@ -716,7 +716,7 @@ class MsmActionPanel extends LitElement {
    + ${summary.inherited} inh @@ -727,7 +727,7 @@ class MsmActionPanel extends LitElement { ` : nothing} +
    ${this.renderPicker( `page-${page.path}`, @@ -748,7 +748,7 @@ class MsmActionPanel extends LitElement {
    ${hasCustomAction ? html`custom` : nothing}
    +
    this.executeRow(page)} ?disabled=${this._busy}>Apply diff --git a/tools/apps/msm/helpers/column-browser.css b/tools/apps/msm/helpers/column-browser.css index caab7d9..27ebd94 100644 --- a/tools/apps/msm/helpers/column-browser.css +++ b/tools/apps/msm/helpers/column-browser.css @@ -8,8 +8,7 @@ display: flex; border: 1px solid var(--s2-gray-200); border-radius: 8px; - overflow-x: auto; - overflow-y: hidden; + overflow: hidden auto; min-height: 320px; max-height: 480px; background: @@ -158,15 +157,15 @@ transform: rotate(45deg); } -.item input[type="checkbox"]:hover:not(:disabled) { - border-color: var(--s2-gray-800); -} - .item input[type="checkbox"]:focus-visible { outline: 2px solid var(--s2-blue-900); outline-offset: 2px; } +.item input[type="checkbox"]:hover:not(:disabled) { + border-color: var(--s2-gray-800); +} + /* ===== Item label and icons ===== */ .item-label { @@ -239,6 +238,7 @@ .browser { position: relative; min-height: 360px; + overflow: hidden; } .column { @@ -250,11 +250,13 @@ background: var(--s2-gray-50, #fafafa); transform: translateX(100%); transition: transform 0.25s ease; + visibility: hidden; z-index: 0; } .column.active { transform: translateX(0); + visibility: visible; z-index: 1; } diff --git a/tools/apps/msm/helpers/column-browser.js b/tools/apps/msm/helpers/column-browser.js index 8e8b1bf..b7183ff 100644 --- a/tools/apps/msm/helpers/column-browser.js +++ b/tools/apps/msm/helpers/column-browser.js @@ -117,6 +117,7 @@ class MsmColumnBrowser extends LitElement { scrollToActiveColumn() { this.updateComplete.then(() => { + if (window.innerWidth <= 600) return; const browser = this.shadowRoot.querySelector('.browser'); if (browser) { browser.scrollTo({ left: browser.scrollWidth, behavior: 'smooth' }); From bdda385b9e959b98da96d76814461a276b1491d4 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Mon, 13 Apr 2026 10:24:10 -0400 Subject: [PATCH 04/26] site status dot removed --- tools/apps/msm/helpers/action-panel.css | 16 +--------------- tools/apps/msm/helpers/action-panel.js | 6 +++--- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/tools/apps/msm/helpers/action-panel.css b/tools/apps/msm/helpers/action-panel.css index 626cd37..4df7ce2 100644 --- a/tools/apps/msm/helpers/action-panel.css +++ b/tools/apps/msm/helpers/action-panel.css @@ -427,27 +427,13 @@ } .override-badge { - display: inline-flex; + display: table-row; align-items: center; gap: 4px; font-size: 12px; color: var(--s2-gray-600); } -.override-badge .dot { - width: 6px; - height: 6px; - border-radius: 50%; -} - -.override-badge .dot.inh { - background: var(--s2-gray-400); -} - -.override-badge .dot.cust { - background: var(--s2-orange-700, #e68619); -} - .row-actions { display: flex; align-items: center; diff --git a/tools/apps/msm/helpers/action-panel.js b/tools/apps/msm/helpers/action-panel.js index f681032..9bfe349 100644 --- a/tools/apps/msm/helpers/action-panel.js +++ b/tools/apps/msm/helpers/action-panel.js @@ -718,11 +718,11 @@ class MsmActionPanel extends LitElement { ${this._isSatellite ? nothing : html`
    - ${summary.inherited} inh + ${summary.inherited} inherited ${summary.custom > 0 ? html` - - ${summary.custom} cust + + ${summary.custom} custom ` : nothing}
    this.executeRow(page)} - ?disabled=${this._busy}>Apply + ?disabled=${this._busy || !this.hasApplicableSats(page.path, action)}>Apply
    diff --git a/tools/apps/msm/helpers/column-browser.css b/tools/apps/msm/helpers/column-browser.css index 27ebd94..cc48bcd 100644 --- a/tools/apps/msm/helpers/column-browser.css +++ b/tools/apps/msm/helpers/column-browser.css @@ -8,7 +8,7 @@ display: flex; border: 1px solid var(--s2-gray-200); border-radius: 8px; - overflow: hidden auto; + overflow: auto; min-height: 320px; max-height: 480px; background: diff --git a/tools/apps/msm/msm.css b/tools/apps/msm/msm.css index e3f88c5..90a2e41 100644 --- a/tools/apps/msm/msm.css +++ b/tools/apps/msm/msm.css @@ -81,6 +81,26 @@ to { transform: rotate(360deg); } } +/* ===== Warning banner ===== */ + +.nx-alert { + border: 2px solid var(--s2-gray-300); + border-radius: 8px; + background: var(--s2-gray-100); + color: var(--s2-gray-900); + margin-bottom: 16px; + padding: 10px 12px; +} + +.nx-alert p { + margin: 0; +} + +.nx-alert.warning { + border-color: var(--s2-orange-500); + background: var(--s2-orange-100); +} + /* ===== Layout ===== */ .msm-body { @@ -101,11 +121,11 @@ } .msm-toolbar-form { - flex-direction: column; gap: 8px; } .msm-toolbar-form sl-input { - width: 100%; + flex: 1; + min-width: 0; } } diff --git a/tools/apps/msm/msm.js b/tools/apps/msm/msm.js index 7518d54..39c146c 100644 --- a/tools/apps/msm/msm.js +++ b/tools/apps/msm/msm.js @@ -36,6 +36,7 @@ class MsmApp extends LitElement { _satellites: { state: true }, _pageOverrides: { state: true }, _initError: { state: true }, + _siteWarning: { state: true }, }; connectedCallback() { @@ -60,6 +61,7 @@ class MsmApp extends LitElement { this._org = org; this._site = site || ''; this._initError = ''; + this._siteWarning = ''; this._selectedItems = []; this._currentPath = ''; this._pageOverrides = new Map(); @@ -68,6 +70,8 @@ class MsmApp extends LitElement { } classifySite(config) { + this._siteWarning = ''; + if (!this._site) { this._role = 'base'; this._baseSite = ''; @@ -94,8 +98,11 @@ class MsmApp extends LitElement { return; } + const fallback = config.baseSites[0]; this._role = 'base'; - this._baseSite = ''; + this._baseSite = fallback.site; + this._satellites = fallback.satellites; + this._siteWarning = `"${this._site}" is not a recognized base or satellite site. Showing "${fallback.site}" instead.`; } async loadConfig(org) { @@ -206,6 +213,7 @@ class MsmApp extends LitElement { } return html` + ${this._siteWarning ? html`

    ${this._siteWarning}

    ` : nothing}
    Date: Mon, 13 Apr 2026 17:22:08 -0400 Subject: [PATCH 08/26] disable apply all when the choosen action doesn't have any applicable satellites --- tools/apps/msm/helpers/action-panel.css | 28 +++++++++++++------------ tools/apps/msm/helpers/action-panel.js | 10 ++++++++- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/tools/apps/msm/helpers/action-panel.css b/tools/apps/msm/helpers/action-panel.css index 9e62767..76d6ded 100644 --- a/tools/apps/msm/helpers/action-panel.css +++ b/tools/apps/msm/helpers/action-panel.css @@ -170,11 +170,11 @@ min-width: 180px; height: 32px; padding: 0 10px; - background: var(--s2-gray-75, #f8f8f8); + background: var(--s2-gray-100, #f5f5f5); font-family: inherit; font-size: 14px; color: var(--s2-gray-800); - border: 1px solid var(--s2-gray-300); + border: 2px solid transparent; border-radius: 8px; cursor: pointer; box-sizing: border-box; @@ -186,18 +186,18 @@ } .picker-trigger:disabled { - background: var(--s2-gray-75); - border-color: var(--s2-gray-200); + background: var(--s2-gray-75, #f8f8f8); + border-color: transparent; color: var(--s2-gray-400); cursor: default; } .picker-trigger:hover:not(:disabled) { - border-color: var(--s2-gray-500); + background: var(--s2-gray-200, #e6e6e6); } .picker-trigger.open { - border-color: var(--s2-blue-900); + background: var(--s2-gray-200, #e6e6e6); } .picker-label { @@ -491,7 +491,7 @@ flex-shrink: 0; } -.result-icon.success { color: #0d6e31; } +.result-icon.success { color: var(--s2-green-900, #05834e); } .result-icon.error { color: var(--s2-red-900, #d31510); } .result-icon.pending { @@ -564,28 +564,30 @@ } .s2-btn-outline:hover { - background: var(--s2-gray-200, #e6e6e6); + background: var(--s2-gray-100, #f5f5f5); + border-color: var(--s2-gray-900, #2c2c2c); } .s2-btn-outline:active { - background: var(--s2-gray-300, #d5d5d5); + background: var(--s2-gray-100, #f5f5f5); + border-color: var(--s2-gray-900, #2c2c2c); } /* S2 negative fill */ .s2-btn-negative { background: var(--s2-red-900, #c9252d); - border-color: var(--s2-red-900, #c9252d); + border-color: transparent; color: #fff; } .s2-btn-negative:hover { background: var(--s2-red-1000, #a41623); - border-color: var(--s2-red-1000, #a41623); + border-color: transparent; } .s2-btn-negative:active { background: var(--s2-red-1100, #8c0f1e); - border-color: var(--s2-red-1100, #8c0f1e); + border-color: transparent; } /* ===== Progress view ===== */ @@ -617,7 +619,7 @@ gap: 16px; } -.progress-summary .count-success { color: #0d6e31; } +.progress-summary .count-success { color: var(--s2-green-900, #05834e); } .progress-summary .count-error { color: var(--s2-red-900); } .progress-list { diff --git a/tools/apps/msm/helpers/action-panel.js b/tools/apps/msm/helpers/action-panel.js index 40b667a..3329b4d 100644 --- a/tools/apps/msm/helpers/action-panel.js +++ b/tools/apps/msm/helpers/action-panel.js @@ -283,6 +283,13 @@ class MsmActionPanel extends LitElement { .some((o) => (scope === 'custom' ? o.hasOverride : !o.hasOverride)); } + get _hasAnyApplicablePages() { + return this._activePages.some((page) => this.hasApplicableSats( + page.path, + this.getPageAction(page.path), + )); + } + getFilteredSatellites() { return Object.entries(this.satellites || {}).filter(([satSite]) => ( this._selectedSats.has(satSite) @@ -633,7 +640,8 @@ class MsmActionPanel extends LitElement {
    this.executeAll()} - ?disabled=${this._busy || this._selectedSats.size === 0 || this._includedPages.size === 0}> + ?disabled=${this._busy || this._selectedSats.size === 0 + || this._includedPages.size === 0 || !this._hasAnyApplicablePages}> Apply All
    From 3e63b519d0b1945a6e7eebaf205eb0bd39a3ca38 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Mon, 13 Apr 2026 19:10:32 -0400 Subject: [PATCH 09/26] UX fixes --- tools/apps/msm/helpers/action-panel.css | 32 +++++++++++++++++-------- tools/apps/msm/helpers/action-panel.js | 15 ++++++++++-- tools/apps/msm/msm.js | 3 ++- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/tools/apps/msm/helpers/action-panel.css b/tools/apps/msm/helpers/action-panel.css index 76d6ded..bc33415 100644 --- a/tools/apps/msm/helpers/action-panel.css +++ b/tools/apps/msm/helpers/action-panel.css @@ -559,34 +559,34 @@ /* S2 secondary outline */ .s2-btn-outline { background: transparent; - border-color: var(--s2-gray-800, #4b4b4b); + border-color: var(--s2-gray-300, #e9e9e9); color: var(--s2-gray-800, #4b4b4b); } .s2-btn-outline:hover { background: var(--s2-gray-100, #f5f5f5); - border-color: var(--s2-gray-900, #2c2c2c); + border-color: var(--s2-gray-400, #c6c6c6); } .s2-btn-outline:active { background: var(--s2-gray-100, #f5f5f5); - border-color: var(--s2-gray-900, #2c2c2c); + border-color: var(--s2-gray-400, #c6c6c6); } -/* S2 negative fill */ -.s2-btn-negative { - background: var(--s2-red-900, #c9252d); +/* S2 confirm fill */ +.s2-btn-confirm { + background: var(--s2-blue-900, #0066cc); border-color: transparent; color: #fff; } -.s2-btn-negative:hover { - background: var(--s2-red-1000, #a41623); +.s2-btn-confirm:hover { + background: var(--s2-blue-1000, #0052a3); border-color: transparent; } -.s2-btn-negative:active { - background: var(--s2-red-1100, #8c0f1e); +.s2-btn-confirm:active { + background: var(--s2-blue-1100, #003d7a); border-color: transparent; } @@ -630,6 +630,18 @@ overflow-y: auto; } +.progress-list::-webkit-scrollbar { width: 5px; } +.progress-list::-webkit-scrollbar-track { background: transparent; } + +.progress-list::-webkit-scrollbar-thumb { + background: var(--s2-gray-300); + border-radius: 3px; +} + +.progress-list::-webkit-scrollbar-thumb:hover { + background: var(--s2-gray-500); +} + .progress-item { display: flex; align-items: center; diff --git a/tools/apps/msm/helpers/action-panel.js b/tools/apps/msm/helpers/action-panel.js index 3329b4d..1bebdc7 100644 --- a/tools/apps/msm/helpers/action-panel.js +++ b/tools/apps/msm/helpers/action-panel.js @@ -182,6 +182,13 @@ class MsmActionPanel extends LitElement { return this._isSatellite ? SAT_ACTION_OPTIONS : BASE_ACTION_OPTIONS; } + _resetExecution() { + if (this._executing) { + this._executing = false; + this._taskStatuses = new Map(); + } + } + // ── Satellite filter ── toggleSatFilter(satSite) { @@ -189,6 +196,7 @@ class MsmActionPanel extends LitElement { if (next.has(satSite)) next.delete(satSite); else next.add(satSite); this._selectedSats = next; + this._resetExecution(); } toggleSingleSat(satSite) { @@ -196,6 +204,7 @@ class MsmActionPanel extends LitElement { if (next.has(satSite)) next.delete(satSite); else next.add(satSite); this._singleSelectedSats = next; + this._resetExecution(); } // ── Per-page action ── @@ -212,6 +221,7 @@ class MsmActionPanel extends LitElement { next.set(pagePath, value); } this._pageActions = next; + this._resetExecution(); } getPageSyncMode(pagePath) { @@ -520,7 +530,7 @@ class MsmActionPanel extends LitElement {

    ${this._confirmAction.message}

    - +
    `; @@ -548,7 +558,7 @@ class MsmActionPanel extends LitElement { 'Action', this._globalAction, this._actionOptions, - (v) => { this._globalAction = v; }, + (v) => { this._globalAction = v; this._resetExecution(); }, )} ${this._globalAction === 'sync' ? this.renderPicker( 'syncMode', @@ -681,6 +691,7 @@ class MsmActionPanel extends LitElement { this._globalAction = v; this._pageActions = new Map(); this._pageSyncModes = new Map(); + this._resetExecution(); }, )} ${this._globalAction === 'sync' ? this.renderPicker( diff --git a/tools/apps/msm/msm.js b/tools/apps/msm/msm.js index 39c146c..40f85bf 100644 --- a/tools/apps/msm/msm.js +++ b/tools/apps/msm/msm.js @@ -174,7 +174,7 @@ class MsmApp extends LitElement { renderToolbar() { return html`
    -

    MSM Actions

    +

    Multi-Site Management

    From 5d965a074628fe995163d4a194ba66b83e6cd0aa Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Tue, 14 Apr 2026 17:01:58 -0400 Subject: [PATCH 10/26] action panel min height remvoed --- tools/apps/msm/helpers/action-panel.css | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/apps/msm/helpers/action-panel.css b/tools/apps/msm/helpers/action-panel.css index bc33415..cfdc13c 100644 --- a/tools/apps/msm/helpers/action-panel.css +++ b/tools/apps/msm/helpers/action-panel.css @@ -8,7 +8,6 @@ border: 1px solid var(--s2-gray-200); border-radius: 8px; background: #fff; - min-height: 400px; } .panel-header { From 400578da19c9dfeec553d99e813d18a35041cba1 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Thu, 21 May 2026 10:26:53 -0400 Subject: [PATCH 11/26] msm app and plugin updates --- ...5-14-msm-multi-level-inheritance-design.md | 467 +++++++++++ package-lock.json | 56 +- tools/apps/msm/README.md | 110 +++ tools/apps/msm/helpers/action-panel.css | 285 ++++++- tools/apps/msm/helpers/action-panel.js | 706 +++++++++++++--- tools/apps/msm/helpers/api.js | 252 +++++- tools/apps/msm/helpers/column-browser.css | 37 + tools/apps/msm/helpers/column-browser.js | 368 +++++++-- tools/apps/msm/msm.css | 108 ++- tools/apps/msm/msm.js | 236 +++++- tools/plugins/msm/README.md | 125 +++ tools/plugins/msm/config.js | 180 +++++ tools/plugins/msm/msm.css | 430 ++++++++++ tools/plugins/msm/msm.html | 27 + tools/plugins/msm/msm.js | 752 ++++++++++++++++++ tools/plugins/msm/utils.js | 110 +++ tools/plugins/msm/vendor/se/components.css | 470 +++++++++++ tools/plugins/msm/vendor/se/components.js | 570 +++++++++++++ 18 files changed, 5035 insertions(+), 254 deletions(-) create mode 100644 docs/superpowers/specs/2026-05-14-msm-multi-level-inheritance-design.md create mode 100644 tools/apps/msm/README.md create mode 100644 tools/plugins/msm/README.md create mode 100644 tools/plugins/msm/config.js create mode 100644 tools/plugins/msm/msm.css create mode 100644 tools/plugins/msm/msm.html create mode 100644 tools/plugins/msm/msm.js create mode 100644 tools/plugins/msm/utils.js create mode 100644 tools/plugins/msm/vendor/se/components.css create mode 100644 tools/plugins/msm/vendor/se/components.js diff --git a/docs/superpowers/specs/2026-05-14-msm-multi-level-inheritance-design.md b/docs/superpowers/specs/2026-05-14-msm-multi-level-inheritance-design.md new file mode 100644 index 0000000..4b183d8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-msm-multi-level-inheritance-design.md @@ -0,0 +1,467 @@ +# MSM Multi-Level Inheritance — Design Spec + +Date: 2026-05-14 +Affects: `tools/apps/msm/` +Reference inputs: +- `msm-design-notes.md` §2 — multi-level inheritance architecture +- `msm-ui-redesign-spec.md` — Spectrum 2 UI redesign for the action panel + +--- + +## 1. Goal + +Extend the MSM tool app to support **multi-level inheritance**, where a single +site can act as both a satellite (has a parent) and a base (has children) — for +example, `europe-west-en` sitting between `europe-en` and `france-en`. + +The data layer for multi-level (`getSiteRoles`, `walkChain`, `walkSubtree`, +descendant counts, `expandSatellitesWithSubtree`) is already in the working +tree. This spec covers the remaining work to surface that capability in the +authoring UI, and to land the Spectrum 2 redesign that the multi-level UX +depends on. + +## 2. Scope + +### In scope + +- **Dual role classification.** Add a third role `'dual'` for sites that appear + both as `base` and as `satellite` in the org `msm` sheet. +- **Direction switch on dual-role pages.** A Spectrum 2 Switch (M) labeled + "Sync from parent" toggles the action picker between downward (push to + children) and upward (pull from parent). +- **Action picker filtering** by direction — the picker shows only the + optgroups relevant to the active direction. No mixed lists. +- **Upward action set.** Two new picker values, `'sync-from-base'` and + `'resume-inheritance'`, aliased in `executeBulkAction` to the existing + `'sync'` / `'reset'` execution paths with `baseSite = parent` and + `satellites = { [self]: … }`. +- **UI redesign deltas from `msm-ui-redesign-spec.md`** that the multi-level + story depends on: + - Static breadcrumb above the switch (`Inherits from a › b › self`) + - Upward summary block (`Source` / `Target` / `Local override`) + - Action row as a fixed `1fr 1fr` grid with conditional sync-mode picker + - Footer cascade toggle moved next to Apply + - Picker label rebrand: `'preview'` → "Roll out to preview", `'publish'` → + "Roll out to live" (action **values** unchanged) + - Confirm dialog restyled as a yellow caution box using `--s2-yellow-*` + fallbacks + +### Out of scope + +- Worker recursion change in the `da-msm` Cloudflare Worker repo +- Translation / `.da/translate.json` integration +- Custom-path support (`pathmap` sheet, ``) +- Worker-side hardening (KV cache, ETag revalidation, batched HEADs, + cross-org scope check) + +## 3. Architecture + +### 3.1 Roles + +``` +classifySite(site): + roles = getSiteRoles(config, site) + if roles.asBase && roles.asSatellite → role = 'dual' + else if roles.asBase → role = 'base' + else if roles.asSatellite → role = 'satellite' + else → fallback to first base, with warning +``` + +`getSiteRoles` already returns `{asBase, asSatellite}` and is unchanged. + +### 3.2 Data flow into the action panel + +`msm.js` exposes both shapes to `` rather than collapsing to +one: + +| Property | Provided when | Purpose | +| ---------------- | ----------------- | --------------------------------------- | +| `role` | always | `'base' \| 'satellite' \| 'dual'` | +| `parentChain` | satellite, dual | Breadcrumb display | +| `parentBase` | satellite, dual | Upward sync source site | +| `satellites` | base, dual | Direct children (label, descCount) | +| `hasDescendants` | base, dual | Cascade toggle visibility | +| `msmConfig` | always | Subtree expansion for cascade | + +In `handleBrowseSelection`, when navigating via the column browser into a +different site (still in base/dual mode), re-classify so dual-role middle-tier +sites carry their parent context across navigation. + +### 3.3 Direction state + +Direction is **derived**, not stored: + +```js +const UPWARD_VALUES = new Set(['sync-from-base', 'resume-inheritance']); +get _isUpwardMode() { return UPWARD_VALUES.has(this._action); } +get _hasDualRole() { return this.role === 'dual'; } +``` + +The Spectrum 2 switch's `checked` reflects `_isUpwardMode`. `onChange` calls: + +```js +onDirectionToggle(toUpward) { + this.onActionChange(toUpward ? 'sync-from-base' : 'preview'); +} +``` + +`onActionChange` resets `_taskStatuses` and `_pageActions`, same path the +picker `@change` already uses. State stays consistent regardless of which +control the user manipulates. + +**Initial `_action` value depends on role**, set in `connectedCallback`: + +| Role | Initial `_action` | Initial `_isUpwardMode` | +| ----------- | ------------------- | ----------------------- | +| `base` | `'preview'` | false | +| `satellite` | `'sync-from-base'` | true | +| `dual` | `'preview'` | false (default OFF) | + +This replaces the existing unconditional `this._globalAction = 'preview'`. + +### 3.4 Action values and direction mapping + +| Picker value | Direction | Executor case (in `executeBulkAction`) | +| --------------------- | --------- | ------------------------------------------------- | +| `preview` | down | `previewSatellite(org, satSite, …)` | +| `publish` | down | `publishSatellite(org, satSite, …)` | +| `break` | down | `createOverride(org, baseSite, satSite, …)` | +| `sync` | down | `mergeFromBase` or `createOverride` | +| `reset` | down | `deleteOverride(org, satSite, …)` | +| `sync-from-base` | up | aliased to `sync` case (params from upward setup) | +| `resume-inheritance` | up | aliased to `reset` case (params from upward setup)| + +Upward setup: in `apply()` / `executeAll()`, when `_isUpwardMode`, the panel +sets `baseSite = this.parentBase` and `satellites = { [this.site]: {…} }` +before calling `executeBulkAction`. The two new `case 'sync-from-base':` and +`case 'resume-inheritance':` lines fall through to the existing `'sync'` / +`'reset'` handlers — no logic duplication. + +`RECURSIVE_ACTIONS` stays `new Set(['preview', 'publish'])`. Cascade is +downward-only by definition. + +### 3.5 Adaptive layout (per `msm-ui-redesign-spec.md` §4) + +| Page role | Switch state | Breadcrumb | Switch | Picker | Children list | Summary | +| ------------- | --------------- | ---------- | ------ | ------------------ | ------------- | ------- | +| Base only | n/a (down) | hidden | no | Down optgroups | shown | no | +| Satellite only| n/a (up) | shown | no | Up optgroup | not rendered | shown | +| Dual | OFF (down) | shown | yes | Down optgroups | shown | no | +| Dual | ON (up) | shown | yes | Up optgroup | hidden | shown | + +## 4. Component-level changes + +### 4.1 `msm.js` + +- Add `'dual'` branch to `classifySite`. +- Replace single `_baseSite` / `_satellites` with both `_parentBase` / + `_parentChain` (upward context) and `_satellites` / `_hasDescendants` + (downward context). `_baseSite` becomes a derived alias of `_parentBase` + for backward-compat reading where needed. +- Pass `parentBase`, `parentChain`, `role` (now possibly `'dual'`), + `satellites`, `hasDescendants`, `msmConfig` to ``. +- `handleBrowseSelection`: when re-classifying the navigated-into site, + populate both upward and downward fields if it's dual. + +### 4.2 `helpers/action-panel.js` + +**State** — no new state variables. `_action`, `_globalAction`, `_pageActions` +already exist. `_isUpwardMode` and `_hasDualRole` are derived getters. + +**New constants:** + +```js +const DOWNWARD_ACTIONS = [ + { heading: 'Inherited sites', items: [ + { value: 'preview', label: 'Roll out to preview' }, + { value: 'publish', label: 'Roll out to live' }, + { value: 'break', label: 'Cancel inheritance' }, + ]}, + { heading: 'Custom sites', items: [ + { value: 'sync', label: 'Sync to satellite' }, + { value: 'reset', label: 'Resume inheritance' }, + ]}, +]; + +const UPWARD_ACTIONS = [ + { heading: 'From parent', items: [ + { value: 'sync-from-base', label: 'Sync from base' }, + { value: 'resume-inheritance', label: 'Resume inheritance' }, + ]}, +]; + +const UPWARD_VALUES = new Set(['sync-from-base', 'resume-inheritance']); +const RECURSIVE_ACTIONS = new Set(['preview', 'publish']); +``` + +`BASE_ACTION_OPTIONS` and `SAT_ACTION_OPTIONS` are removed; `_actionOptions` +returns `_isUpwardMode ? UPWARD_ACTIONS : DOWNWARD_ACTIONS`. + +**New render helpers:** + +``` +renderBreadcrumb() // satellite/dual: static "Inherits from a › b › self" +renderDirectionSwitch() // dual only: native checkbox styled as S2 Switch +renderUpwardSummary() // satellite/dual+upward: Source/Target/Local override +renderFooter() // cascade toggle (left) + Apply (right) +renderConfirm() // yellow caution box (restyled .alert-dialog) +``` + +`renderActionPicker()` builds optgroups based on `_isUpwardMode`. +`renderChildrenList()` (existing `renderSatelliteGrid`) returns `nothing` when +`_isUpwardMode`. `renderProgress()` and `renderPageRow()` are unchanged. + +**Main `render()`:** + +``` +breadcrumb? +direction switch? +action row (picker + conditional sync-mode) +upward summary? +children list? OR page table? +progress? +footer (cascade + Apply) +confirm? +``` + +`renderSinglePage` and `renderBulk` are simplified to share this skeleton via +a single top-level `renderPanel(mode)` function. The two modes differ only in +which subtree renders inside the body slot (satellite grid vs page table). + +**Apply path:** + +```js +async apply() { + if (this._isUpwardMode) { + return this._applyUpward(); // baseSite = parentBase, sats = {[self]: …} + } + return this._applyDownward(); // existing executeAll() path +} +``` + +`_applyUpward()` reuses `executeBulkAction` with the upward parameter setup, +hitting the aliased `case 'sync-from-base':` / `case 'resume-inheritance':` +branches in `api.js`. + +### 4.3 `helpers/api.js` + +Add two `case` aliases in the `executeBulkAction` switch: + +```js +case 'sync': +case 'sync-from-base': + result = syncMode === 'merge' + ? await mergeFromBase(org, baseSite, satSite, pagePath, ext) + : await createOverride(org, baseSite, satSite, pagePath, ext); + break; +case 'reset': +case 'resume-inheritance': { + // existing reset block, unchanged +} +``` + +No other API changes. `getSiteRoles`, `walkChain`, `walkSubtree`, +`getDescendantCount`, `expandSatellitesWithSubtree` — all unchanged. + +### 4.4 `helpers/action-panel.css` + +**Add:** + +- `.crumb-row` — single static horizontal row, no card, no click affordance +- `.direction-switch` — S2 Switch (M) lookalike: 26×14 pill track, 10px white + knob, `gray-300` off-track, `blue-700` on-track, 14px label "Sync from + parent" to the right +- `.upward-summary` — flex column of `
    ${summary.inherited} inherited @@ -773,17 +1260,17 @@ class MsmActionPanel extends LitElement { ` : nothing}
    ${this.renderPicker( `page-${page.path}`, '', action, - this._actionOptions, + this._actionOptionsForPage(page), (v) => this.setPageAction(page.path, v), )} -
    +
    ${this.renderPicker( `page-sync-${page.path}`, '', @@ -797,11 +1284,11 @@ class MsmActionPanel extends LitElement {
    this.executeRow(page)} - ?disabled=${this._busy || !this.hasApplicableSats(page.path, action)}>Apply + ?disabled=${this._busy || !this._canApplySingle(page)}>Apply
    +
    ${inherited.length ? html`
    @@ -836,7 +1324,7 @@ class MsmActionPanel extends LitElement {
  • ${this.statusIcon(this._taskStatuses.get(`${page.path}:${sat.site}`)?.status)} ${sat.label} - + ${EDIT_ICON}
  • @@ -871,7 +1359,7 @@ class MsmActionPanel extends LitElement {
      ${[...this._taskStatuses.entries()].map(([key, { status, error }]) => { const [pagePath, satSite] = key.split(':'); - const pageName = pagePath.split('/').pop().replace('.html', ''); + const pageName = pagePath.split('/').pop().replace(/\.[^/.]+$/, ''); const satLabel = this.satellites?.[satSite]?.label || satSite; return html`
    • diff --git a/tools/apps/msm/helpers/api.js b/tools/apps/msm/helpers/api.js index acdd5ac..7cf3318 100644 --- a/tools/apps/msm/helpers/api.js +++ b/tools/apps/msm/helpers/api.js @@ -4,6 +4,21 @@ const DA_ORIGIN = 'https://admin.da.live'; const AEM_ADMIN = 'https://admin.hlx.page'; const MAX_CONCURRENT = 5; +export const ACTIONABLE_EXTENSIONS = new Set(['html', 'json', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf']); + +export function isActionableItem(item) { + return !item.isFolder && !item.isSite && ACTIONABLE_EXTENSIONS.has(item.ext); +} + +function stripExtension(filePath) { + return filePath.replace(/\.[^/.]+$/, ''); +} + +function getExtension(filePath) { + const match = filePath.match(/\.([^/.]+)$/); + return match ? match[1] : ''; +} + let daFetchFn; async function ensureDaFetch() { @@ -82,6 +97,106 @@ function resolveBaseSites(rows) { return [...baseSites.values()].filter((b) => Object.keys(b.satellites).length > 0); } +// ────────────────────────────────────────────── +// Multi-level inheritance helpers +// ────────────────────────────────────────────── + +function getParentRow(rows, site) { + return rows.find((row) => row.satellite === site); +} + +function getBaseLabel(rows, site) { + const labelRow = rows.find((row) => row.base === site && !row.satellite); + return labelRow?.title; +} + +function getDirectChildren(rows, site) { + return rows + .filter((row) => row.base === site && row.satellite) + .map((row) => ({ site: row.satellite, label: row.title || row.satellite })); +} + +function walkSubtree(rows, rootSite, visited = new Set()) { + if (visited.has(rootSite)) return []; + visited.add(rootSite); + const children = getDirectChildren(rows, rootSite); + return children.flatMap((child) => [ + child, + ...walkSubtree(rows, child.site, visited), + ]); +} + +function walkChain(rows, site) { + const chain = []; + const visited = new Set(); + let current = site; + while (current && !visited.has(current)) { + visited.add(current); + const parentRow = getParentRow(rows, current); + if (!parentRow) break; + chain.unshift({ + site: parentRow.base, + label: getBaseLabel(rows, parentRow.base) || parentRow.base, + }); + current = parentRow.base; + } + return chain; +} + +export function getInheritanceChain(config, site) { + return walkChain(config?.rows || [], site); +} + +export function getSubtreeSites(config, rootSite) { + return walkSubtree(config?.rows || [], rootSite); +} + +export function getDescendantCount(config, site) { + return getSubtreeSites(config, site).length; +} + +export function getSiteRoles(config, site) { + const rows = config?.rows || []; + const children = getDirectChildren(rows, site); + const parentRow = getParentRow(rows, site); + const result = {}; + if (children.length) { + const satellites = children.reduce((acc, child) => { + acc[child.site] = { + label: child.label, + descendantCount: walkSubtree(rows, child.site).length, + }; + return acc; + }, {}); + result.asBase = { + baseLabel: getBaseLabel(rows, site), + satellites, + }; + } + if (parentRow) { + result.asSatellite = { + base: parentRow.base, + baseLabel: getBaseLabel(rows, parentRow.base) || parentRow.base, + chain: walkChain(rows, site), + }; + } + return result; +} + +export function expandSatellitesWithSubtree(config, directSatellites) { + if (!config || !directSatellites) return directSatellites || {}; + const expanded = { ...directSatellites }; + Object.keys(directSatellites).forEach((siteName) => { + const subtree = getSubtreeSites(config, siteName); + subtree.forEach((node) => { + if (!expanded[node.site]) { + expanded[node.site] = { label: node.label }; + } + }); + }); + return expanded; +} + export async function fetchMsmConfig(org) { if (configCache[org]) return configCache[org]; @@ -91,7 +206,7 @@ export async function fetchMsmConfig(org) { const baseSites = resolveBaseSites(rows); if (!baseSites.length) return null; - const config = { baseSites }; + const config = { baseSites, rows }; configCache[org] = config; return config; } @@ -121,15 +236,66 @@ export async function listFolder(org, site, path = '/') { }); } +// ────────────────────────────────────────────── +// Inheritance-aware folder listing +// ────────────────────────────────────────────── + +// Lists a folder for a satellite site and merges in inherited entries from the +// ancestor chain. Returns the same item shape as `listFolder` with three extra +// fields on every item: +// - sourceSite : where the file actually lives (current site or an ancestor) +// - inheritedFrom : null when local, ancestor site name when inherited +// - hasLocalOverride : true iff the path exists in both the current site AND +// in some ancestor (i.e. the local file overrides a base) +// Closest-source-wins: when an item exists at multiple levels, the level +// nearest to the current site (lowest in the chain) decides the source. +export async function listFolderWithInheritance(org, site, path, msmConfig) { + const chain = msmConfig ? getInheritanceChain(msmConfig, site) : []; + if (!chain.length) { + const items = await listFolder(org, site, path); + return items.map((i) => ({ + ...i, + sourceSite: site, + inheritedFrom: null, + hasLocalOverride: false, + })); + } + + // walk[0] = self; walk[1..] = ancestors in nearest-first order. + const walk = [{ site }, ...chain.slice().reverse()]; + const lists = await Promise.all( + walk.map((node) => listFolder(org, node.site, path).catch(() => [])), + ); + + const seen = new Map(); + lists.forEach((items, idx) => { + const node = walk[idx]; + const isLocal = idx === 0; + items.forEach((item) => { + if (seen.has(item.path)) return; + seen.set(item.path, { + ...item, + site, + sourceSite: node.site, + inheritedFrom: isLocal ? null : node.site, + hasLocalOverride: isLocal + && lists.slice(1).some((arr) => arr.some((i) => i.path === item.path)), + }); + }); + }); + + return [...seen.values()]; +} + // ────────────────────────────────────────────── // Override checking // ────────────────────────────────────────────── -export async function checkPageOverrides(org, satellites, pagePath) { +export async function checkPageOverrides(org, satellites, pagePath, ext = 'html') { const entries = Object.entries(satellites); const results = await Promise.all( entries.map(async ([site, info]) => { - const url = `${DA_ORIGIN}/source/${org}/${site}${pagePath}.html`; + const url = `${DA_ORIGIN}/source/${org}/${site}${pagePath}.${ext}`; const resp = await daFetch(url, { method: 'HEAD' }); return { site, label: info.label, hasOverride: resp.ok }; }), @@ -141,8 +307,8 @@ export async function checkPageOverrides(org, satellites, pagePath) { // Preview / Publish // ────────────────────────────────────────────── -export async function previewSatellite(org, satellite, pagePath) { - const aemPath = pagePath.replace(/\.html$/, ''); +export async function previewSatellite(org, satellite, pagePath, ext = 'html') { + const aemPath = ext === 'html' ? pagePath : `${pagePath}.${ext}`; const url = `${AEM_ADMIN}/preview/${org}/${satellite}/main${aemPath}`; const resp = await daFetch(url, { method: 'POST' }); if (!resp.ok) { @@ -152,8 +318,8 @@ export async function previewSatellite(org, satellite, pagePath) { return resp.json(); } -export async function publishSatellite(org, satellite, pagePath) { - const aemPath = pagePath.replace(/\.html$/, ''); +export async function publishSatellite(org, satellite, pagePath, ext = 'html') { + const aemPath = ext === 'html' ? pagePath : `${pagePath}.${ext}`; const url = `${AEM_ADMIN}/live/${org}/${satellite}/main${aemPath}`; const resp = await daFetch(url, { method: 'POST' }); if (!resp.ok) { @@ -167,31 +333,44 @@ export async function publishSatellite(org, satellite, pagePath) { // Override management // ────────────────────────────────────────────── -export async function createOverride(org, baseSite, satellite, pagePath) { - const basePath = `${DA_ORIGIN}/source/${org}/${baseSite}${pagePath}.html`; +const EXT_MIME_TYPES = { + html: 'text/html', + json: 'application/json', + svg: 'image/svg+xml', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + pdf: 'application/pdf', +}; + +export async function createOverride(org, baseSite, satellite, pagePath, ext = 'html') { + const basePath = `${DA_ORIGIN}/source/${org}/${baseSite}${pagePath}.${ext}`; const resp = await daFetch(basePath); if (!resp.ok) return { error: `Failed to fetch base content (${resp.status})` }; - const content = await resp.text(); - const blob = new Blob([content], { type: 'text/html' }); + const content = await resp.blob(); + const mimeType = EXT_MIME_TYPES[ext] || 'application/octet-stream'; + const blob = new Blob([content], { type: mimeType }); const formData = new FormData(); formData.append('data', blob); - const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.html`; + const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.${ext}`; const saveResp = await daFetch(satPath, { method: 'PUT', body: formData }); if (!saveResp.ok) return { error: `Failed to create override (${saveResp.status})` }; return { ok: true }; } -export async function deleteOverride(org, satellite, pagePath) { - const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.html`; +export async function deleteOverride(org, satellite, pagePath, ext = 'html') { + const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.${ext}`; const resp = await daFetch(satPath, { method: 'DELETE' }); if (!resp.ok) return { error: `Failed to delete override (${resp.status})` }; return { ok: true }; } -export async function getSatellitePageStatus(org, satellite, pagePath) { - const aemPath = pagePath.replace(/\.html$/, ''); +export async function getSatellitePageStatus(org, satellite, pagePath, ext = 'html') { + const aemPath = ext === 'html' ? pagePath : `${pagePath}.${ext}`; const url = `${AEM_ADMIN}/status/${org}/${satellite}/main${aemPath}`; const resp = await daFetch(url); if (!resp.ok) return { preview: false, live: false }; @@ -216,12 +395,12 @@ async function ensureMergeCopy() { return mergeCopyFn; } -export async function mergeFromBase(org, baseSite, satellite, pagePath) { +export async function mergeFromBase(org, baseSite, satellite, pagePath, ext = 'html') { try { const mergeCopy = await ensureMergeCopy(); const url = { - source: `/${org}/${baseSite}${pagePath}.html`, - destination: `/${org}/${satellite}${pagePath}.html`, + source: `/${org}/${baseSite}${pagePath}.${ext}`, + destination: `/${org}/${satellite}${pagePath}.${ext}`, }; const result = await mergeCopy(url, 'MSM Merge'); if (!result?.ok) return { error: 'Merge failed' }; @@ -250,7 +429,8 @@ export async function executeBulkAction({ const satEntries = Object.entries(satellites); const tasks = pages.flatMap((page) => { - const pagePath = page.path.replace(/\.html$/, ''); + const ext = getExtension(page.path) || 'html'; + const pagePath = stripExtension(page.path); const pageOverrides = overrides?.get(page.path) || []; const applicableSats = scope @@ -268,6 +448,10 @@ export async function executeBulkAction({ skipped.forEach((s) => onSkipped?.(page, s, scope)); } + applicableSats.forEach(([satSite]) => { + onPageStatus?.(`${page.path}:${satSite}`, 'queued'); + }); + return applicableSats.map(([satSite]) => async () => { const key = `${page.path}:${satSite}`; onPageStatus?.(key, 'pending'); @@ -276,28 +460,34 @@ export async function executeBulkAction({ let result; switch (action) { case 'preview': - result = await previewSatellite(org, satSite, pagePath); + result = await previewSatellite(org, satSite, pagePath, ext); break; case 'publish': - result = await publishSatellite(org, satSite, pagePath); + result = await publishSatellite(org, satSite, pagePath, ext); break; case 'break': - result = await createOverride(org, baseSite, satSite, pagePath); + case 'cancel-inheritance': + // 'cancel-inheritance' is the upward (satellite-side) framing of + // the same operation: materialize the inherited page locally, + // breaking the inheritance link. + result = await createOverride(org, baseSite, satSite, pagePath, ext); break; case 'sync': + case 'sync-from-base': result = syncMode === 'merge' - ? await mergeFromBase(org, baseSite, satSite, pagePath) - : await createOverride(org, baseSite, satSite, pagePath); + ? await mergeFromBase(org, baseSite, satSite, pagePath, ext) + : await createOverride(org, baseSite, satSite, pagePath, ext); break; - case 'reset': { - const pageStatus = await getSatellitePageStatus(org, satSite, pagePath); - result = await deleteOverride(org, satSite, pagePath); + case 'reset': + case 'resume-inheritance': { + const pageStatus = await getSatellitePageStatus(org, satSite, pagePath, ext); + result = await deleteOverride(org, satSite, pagePath, ext); if (!result?.error) { if (pageStatus.live) { - await previewSatellite(org, satSite, pagePath); - await publishSatellite(org, satSite, pagePath); + await previewSatellite(org, satSite, pagePath, ext); + await publishSatellite(org, satSite, pagePath, ext); } else if (pageStatus.preview) { - await previewSatellite(org, satSite, pagePath); + await previewSatellite(org, satSite, pagePath, ext); } } break; diff --git a/tools/apps/msm/helpers/column-browser.css b/tools/apps/msm/helpers/column-browser.css index cc48bcd..ef40f95 100644 --- a/tools/apps/msm/helpers/column-browser.css +++ b/tools/apps/msm/helpers/column-browser.css @@ -189,6 +189,43 @@ color: var(--s2-gray-500); } +/* ===== Inherited item (page or folder served via MSM fallback) ===== */ + +.item.inherited .item-label { + color: var(--s2-gray-600, #6e6e6e); +} + +.item.inherited .item-icon { + opacity: 0.7; +} + +.inherited-badge { + flex-shrink: 0; + width: 14px; + height: 14px; + margin-left: -2px; + color: var(--s2-gray-700, #505050); +} + +/* ===== Blocked item (selection mutex: different category from current) ===== */ + +.item.blocked { + opacity: 0.45; + cursor: not-allowed; +} + +.item.blocked .item-label, +.item.blocked .item-icon, +.item.blocked .inherited-badge { + pointer-events: none; +} + +.item.blocked input[type="checkbox"]:disabled { + cursor: not-allowed; + border-color: var(--s2-gray-400, #b1b1b1); + background: transparent; +} + /* ===== Override dot indicator ===== */ .override-dot { diff --git a/tools/apps/msm/helpers/column-browser.js b/tools/apps/msm/helpers/column-browser.js index b7183ff..f252eec 100644 --- a/tools/apps/msm/helpers/column-browser.js +++ b/tools/apps/msm/helpers/column-browser.js @@ -1,6 +1,6 @@ /* eslint-disable no-underscore-dangle, import/no-unresolved, no-console, class-methods-use-this */ import { LitElement, html, nothing } from 'da-lit'; -import { listFolder } from './api.js'; +import { listFolder, listFolderWithInheritance, isActionableItem } from './api.js'; const NX = 'https://da.live/nx'; let sl; @@ -19,6 +19,7 @@ const FOLDER_ICON = html``; const ARROW_RIGHT = html``; const BACK_ARROW = html``; +const INHERITED_BADGE = html``; class MsmColumnBrowser extends LitElement { static properties = { @@ -26,11 +27,14 @@ class MsmColumnBrowser extends LitElement { role: { type: String }, site: { type: String }, msmConfig: { attribute: false }, + hideInherited: { type: Boolean }, + deepLinkPath: { type: String }, _columns: { state: true }, _checked: { state: true }, _activeColumnIdx: { state: true }, _loadingColumn: { state: true }, - _focusedItemIdx: { state: true }, + _focusedItem: { state: true }, + _selectionCategory: { state: true }, }; connectedCallback() { @@ -40,8 +44,13 @@ class MsmColumnBrowser extends LitElement { this._checked = new Set(); this._activeColumnIdx = 0; this._loadingColumn = -1; - this._focusedItemIdx = -1; + this._focusedItem = null; + this._selectionCategory = null; this._folderCache = new Map(); + this._mergedFolderCache = new Map(); + // Monotonic counter used by `emitSelection` to detect and drop stale + // dispatches when the user check/unchecks faster than folder pages load. + this._emitSeq = 0; this._handleKeydown = this._onKeydown.bind(this); if (this.site) { @@ -51,54 +60,153 @@ class MsmColumnBrowser extends LitElement { } } + updated(changed) { + if (changed.has('hideInherited') && this.hideInherited) { + this._pruneHiddenInheritedChecks(); + } + } + + _setFocus(columnIdx, item) { + this._focusedItem = item ? { + columnIdx, + path: item.path, + site: item.site || '', + } : null; + } + + _getActiveFocusedIdx() { + const f = this._focusedItem; + if (!f || f.columnIdx !== this._activeColumnIdx) return -1; + const col = this._columns[this._activeColumnIdx]; + if (!col) return -1; + const items = this._visibleItems(col); + return items.findIndex((it) => ( + it.path === f.path && (it.site || '') === f.site + )); + } + + _pruneHiddenInheritedChecks() { + if (!this._checked.size) return; + const itemByKey = new Map(); + this._columns.forEach((col) => { + col.items.forEach((it) => { + itemByKey.set(`${it.site || ''}:${it.path}`, it); + }); + }); + + const next = new Set(this._checked); + let modified = false; + this._checked.forEach((key) => { + const it = itemByKey.get(key); + if (it?.inheritedFrom) { + next.delete(key); + modified = true; + } + }); + if (!modified) return; + + this._checked = next; + this._focusedItem = null; + this._refreshSelectionCategory(); + this.emitSelection(this.getCurrentSite()); + } + + _itemCategory(item) { + if (item.inheritedFrom) return 'inherited'; + if (item.hasLocalOverride) return 'overridden'; + return 'local'; + } + + _isCheckBlocked(item) { + if (!this._selectionCategory) return false; + if (this.isItemChecked(item)) return false; + return this._itemCategory(item) !== this._selectionCategory; + } + + invalidateMergedCache() { + this._mergedFolderCache = new Map(); + } + + _siteHasInheritance(site) { + if (!this.msmConfig || !site) return false; + return (this.msmConfig.rows || []) + .some((row) => row.satellite === site); + } + + async _loadFolderItems(site, path) { + if (this._siteHasInheritance(site)) { + const cacheKey = `${site}::${path}`; + if (this._mergedFolderCache.has(cacheKey)) { + return this._mergedFolderCache.get(cacheKey); + } + const items = await listFolderWithInheritance(this.org, site, path, this.msmConfig); + this._mergedFolderCache.set(cacheKey, items); + return items; + } + return listFolder(this.org, site, path); + } + disconnectedCallback() { super.disconnectedCallback(); } _onKeydown(e) { const col = this._columns[this._activeColumnIdx]; - if (!col?.items.length) return; + if (!col) return; + const items = this._visibleItems(col); + if (!items.length) return; + const curIdx = this._getActiveFocusedIdx(); switch (e.key) { - case 'ArrowDown': + case 'ArrowDown': { e.preventDefault(); - this._focusedItemIdx = Math.min( - this._focusedItemIdx + 1, - col.items.length - 1, - ); + const nextIdx = Math.min(curIdx + 1, items.length - 1); + this._setFocus(this._activeColumnIdx, items[nextIdx]); this.scrollFocusedIntoView(); break; - case 'ArrowUp': + } + case 'ArrowUp': { e.preventDefault(); - this._focusedItemIdx = Math.max(this._focusedItemIdx - 1, 0); + const prevIdx = Math.max(curIdx - 1, 0); + this._setFocus(this._activeColumnIdx, items[prevIdx]); this.scrollFocusedIntoView(); break; + } case 'ArrowRight': case 'Enter': { e.preventDefault(); - const item = col.items[this._focusedItemIdx]; + const item = items[curIdx]; if (item && (item.isFolder || item.isSite)) { - this.navigateToFolder(this._activeColumnIdx, item); - this._focusedItemIdx = 0; + const fromColumn = this._activeColumnIdx; + this.navigateToFolder(fromColumn, item).then(() => { + const newCol = this._columns[fromColumn + 1]; + const visible = newCol ? this._visibleItems(newCol) : []; + if (visible.length) { + this._setFocus(fromColumn + 1, visible[0]); + this.scrollFocusedIntoView(); + } + }); } break; } - case 'ArrowLeft': + case 'ArrowLeft': { e.preventDefault(); if (this._activeColumnIdx > 0) { this._activeColumnIdx -= 1; - this._focusedItemIdx = 0; + const prevCol = this._columns[this._activeColumnIdx]; + const visible = prevCol ? this._visibleItems(prevCol) : []; + this._setFocus(this._activeColumnIdx, visible[0] || null); } break; - case ' ': + } + case ' ': { e.preventDefault(); - if (this._focusedItemIdx >= 0) { - const item = col.items[this._focusedItemIdx]; - if (item && this.showCheckbox(item)) { - this.toggleCheck(item); - } + const item = items[curIdx]; + if (item && this.showCheckbox(item)) { + this.toggleCheck(item, this._activeColumnIdx); } break; + } default: break; } @@ -106,12 +214,16 @@ class MsmColumnBrowser extends LitElement { scrollFocusedIntoView() { this.updateComplete.then(() => { - const items = this.shadowRoot.querySelectorAll( - `.column:nth-child(${this._activeColumnIdx + 1}) .item`, + const f = this._focusedItem; + if (!f) return; + const els = this.shadowRoot.querySelectorAll( + `.column:nth-child(${f.columnIdx + 1}) .item`, ); - if (items[this._focusedItemIdx]) { - items[this._focusedItemIdx].scrollIntoView({ block: 'nearest' }); - } + const target = Array.from(els).find((el) => ( + el.dataset.path === f.path + && (el.dataset.site || '') === f.site + )); + if (target) target.scrollIntoView({ block: 'nearest' }); }); } @@ -125,16 +237,45 @@ class MsmColumnBrowser extends LitElement { }); } - toggleCheck(item) { + toggleCheck(item, colIdx) { + if (this._isCheckBlocked(item)) return; + // The user is acting on column `colIdx`; any deeper columns belong to a + // previously-opened folder that's no longer the current focus. Collapse + // them so the browser reflects the user's new context. + if (Number.isInteger(colIdx)) this._collapseColumnsAfter(colIdx); const next = new Set(this._checked); const key = `${item.site || ''}:${item.path}`; - if (next.has(key)) next.delete(key); + const willUncheck = next.has(key); + if (willUncheck) next.delete(key); else next.add(key); this._checked = next; + if (willUncheck) this._clearFocusIfMatches(item); + this._refreshSelectionCategory(); const site = item.site || this.getCurrentSite(); this.emitSelection(site); } + // Drops every column to the right of `colIdx` and prunes any checks that + // lived in those discarded columns. No-op when `colIdx` is already the + // rightmost column. + _collapseColumnsAfter(colIdx) { + if (colIdx >= this._columns.length - 1) return; + this._columns = this._columns.slice(0, colIdx + 1); + if (this._activeColumnIdx > colIdx) this._activeColumnIdx = colIdx; + if (this._focusedItem && this._focusedItem.columnIdx > colIdx) { + this._focusedItem = null; + } + this.clearChecksAfterColumn(colIdx); + } + + _clearFocusIfMatches(item) { + const f = this._focusedItem; + if (!f) return; + if (f.path === item.path && f.site === (item.site || '')) { + this._focusedItem = null; + } + } + initSitesColumn() { if (!this.msmConfig?.baseSites?.length) return; const items = this.msmConfig.baseSites.map((bs) => ({ @@ -154,7 +295,7 @@ class MsmColumnBrowser extends LitElement { }]; try { - const items = await listFolder(this.org, this.site, '/'); + const items = await this._loadFolderItems(this.site, '/'); this._columns = [{ header: this.site, items: items.map((i) => ({ ...i, site: this.site })), @@ -165,6 +306,76 @@ class MsmColumnBrowser extends LitElement { this._columns = [{ header: this.site, items: [], selectedPath: null }]; } this._loadingColumn = -1; + + if (this.deepLinkPath && !this._deepLinkConsumed) { + this._deepLinkConsumed = true; + await this._navigateToPath(this.deepLinkPath); + } + } + + async _navigateToPath(path) { + const requested = path; + const normalized = path.startsWith('/') ? path : `/${path}`; + const parts = normalized.split('/').filter(Boolean); + + this._suppressEmit = true; + let colIdx = 0; + let cumPath = ''; + let lastResolved = ''; + let resolvedFully = parts.length === 0; + + try { + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < parts.length; i += 1) { + cumPath += `/${parts[i]}`; + const stepPath = cumPath; + const col = this._columns[colIdx]; + if (!col) break; + + const isLast = i === parts.length - 1; + let item = col.items.find((it) => it.path === stepPath); + if (!item && isLast && !/\.[a-z0-9]+$/i.test(parts[i])) { + // Fall back to `.html` when the last segment lacks an extension. + const htmlPath = `${stepPath}.html`; + item = col.items.find((it) => it.path === htmlPath); + } + if (!item) break; + lastResolved = item.path; + + if (isLast) { + if (item.isFolder || item.isSite) { + await this.navigateToFolder(colIdx, item); + } else if (this.showCheckbox(item)) { + this.toggleCheck(item, colIdx); + this._setFocus(colIdx, item); + } + resolvedFully = true; + } else if (item.isFolder || item.isSite) { + await this.navigateToFolder(colIdx, item); + colIdx += 1; + } else { + // Path expects a folder here but got a page; stop the walk. + break; + } + } + /* eslint-enable no-await-in-loop */ + } finally { + this._suppressEmit = false; + } + + await this.emitSelection(this.getCurrentSite()); + + if (!resolvedFully) { + this.dispatchEvent(new CustomEvent('deep-link-warning', { + detail: { requestedPath: requested, lastResolvedPath: lastResolved }, + bubbles: true, + composed: true, + })); + } + this.dispatchEvent(new CustomEvent('deep-link-consumed', { + bubbles: true, + composed: true, + })); } async navigateToFolder(colIdx, item) { @@ -180,9 +391,9 @@ class MsmColumnBrowser extends LitElement { try { let items; if (item.isSite) { - items = await listFolder(this.org, site, '/'); + items = await this._loadFolderItems(site, '/'); } else { - items = await listFolder(this.org, site, item.path); + items = await this._loadFolderItems(site, item.path); } items = items.map((i) => ({ ...i, site })); @@ -245,25 +456,50 @@ class MsmColumnBrowser extends LitElement { if (item.isFolder || item.isSite) { this.navigateToFolder(colIdx, item); } else { - this.toggleCheck(item); + this.toggleCheck(item, colIdx); } } - handleCheckChange(item, e) { + handleCheckChange(item, e, colIdx) { e.stopPropagation(); + if (this._isCheckBlocked(item)) { + e.target.checked = false; + return; + } + if (Number.isInteger(colIdx)) this._collapseColumnsAfter(colIdx); const next = new Set(this._checked); const key = `${item.site || ''}:${item.path}`; if (e.target.checked) { next.add(key); } else { next.delete(key); + this._clearFocusIfMatches(item); } this._checked = next; + this._refreshSelectionCategory(); const site = item.site || this.getCurrentSite(); this.emitSelection(site); } + _refreshSelectionCategory() { + if (this._checked.size === 0) { + this._selectionCategory = null; + return; + } + const firstKey = this._checked.values().next().value; + let category = null; + this._columns.some((col) => col.items.some((item) => { + const key = `${item.site || ''}:${item.path}`; + if (key === firstKey) { + category = this._itemCategory(item); + return true; + } + return false; + })); + this._selectionCategory = category; + } + clearChecksAfterColumn(colIdx) { const validPaths = new Set( this._columns.slice(0, colIdx + 1).flatMap( @@ -271,16 +507,25 @@ class MsmColumnBrowser extends LitElement { ), ); this._checked = new Set([...this._checked].filter((key) => validPaths.has(key))); + this._refreshSelectionCategory(); } async emitSelection(site) { + if (this._suppressEmit) return; + // Snapshot `_checked` synchronously so a later check/uncheck that fires + // its own `emitSelection` can't mutate what this call considers selected. + // Combined with the seq check below, this guarantees an in-flight stale + // dispatch can never re-introduce items the user has just unchecked. + this._emitSeq += 1; + const mySeq = this._emitSeq; + const checkedSnapshot = new Set(this._checked); const checkedPages = []; const checkedFolders = []; this._columns.forEach((col) => { col.items.forEach((item) => { const key = `${item.site || site || ''}:${item.path}`; - if (!this._checked.has(key)) return; + if (!checkedSnapshot.has(key)) return; if (item.isFolder && !item.isSite) { checkedFolders.push(item); } else if (!item.isSite) { @@ -295,11 +540,11 @@ class MsmColumnBrowser extends LitElement { const cacheKey = `${this.org}/${folderSite}${folder.path}`; if (!this._folderCache.has(cacheKey)) { try { - const items = await listFolder(this.org, folderSite, folder.path); - this._folderCache.set( - cacheKey, - items.filter((i) => i.ext === 'html').map((i) => ({ ...i, site: folderSite })), - ); + const items = await this._loadFolderItems(folderSite, folder.path); + const files = items + .filter((i) => !i.isFolder && !i.isSite) + .map((i) => ({ ...i, site: folderSite })); + this._folderCache.set(cacheKey, files); } catch (e) { console.error('Failed to resolve folder pages:', e); this._folderCache.set(cacheKey, []); @@ -309,6 +554,11 @@ class MsmColumnBrowser extends LitElement { }), ); + // A newer `emitSelection` has started (user clicked again while we were + // awaiting folder loads); drop this stale result instead of overriding + // the newer dispatch with files for a no-longer-checked folder. + if (mySeq !== this._emitSeq) return; + const allItems = [...checkedPages, ...folderPages.flat()]; const seen = new Set(); const selectedItems = allItems.filter((item) => { @@ -339,17 +589,31 @@ class MsmColumnBrowser extends LitElement { } showCheckbox(item) { - return !item.isSite && (item.ext === 'html' || item.isFolder); + return !item.isSite && (isActionableItem(item) || item.isFolder); } - renderItem(colIdx, item, itemIdx) { + renderItem(colIdx, item) { const isSelected = this._columns[colIdx]?.selectedPath === item.path; - const isFocused = colIdx === this._activeColumnIdx - && itemIdx === this._focusedItemIdx; + const f = this._focusedItem; + const isFocused = !!f + && f.columnIdx === colIdx + && f.path === item.path + && f.site === (item.site || ''); + const isInherited = !!item.inheritedFrom; + const blocked = this._isCheckBlocked(item); + const tooltipParts = []; + if (isInherited) tooltipParts.push(`Inherited from ${item.inheritedFrom}`); + if (blocked) { + tooltipParts.push(`Cannot mix with ${this._selectionCategory} pages. Clear the selection to switch categories.`); + } + const title = tooltipParts.join(' \u2022 ') || undefined; return html`
      this.handleItemClick(colIdx, item, e)} + title=${title || nothing} role="option" aria-selected=${isSelected} > @@ -357,20 +621,28 @@ class MsmColumnBrowser extends LitElement { this.handleCheckChange(item, e)} + ?disabled=${blocked} + @change=${(e) => this.handleCheckChange(item, e, colIdx)} @click=${(e) => e.stopPropagation()} /> ` : nothing} ${item.isFolder || item.isSite ? FOLDER_ICON : PAGE_ICON} + ${isInherited ? INHERITED_BADGE : nothing} ${item.name} ${item.isFolder || item.isSite ? ARROW_RIGHT : nothing}
      `; } + _visibleItems(col) { + if (!this.hideInherited) return col.items; + return col.items.filter((i) => !i.inheritedFrom); + } + renderColumn(col, colIdx) { const isActive = colIdx === this._activeColumnIdx; const isLoading = this._loadingColumn === colIdx; + const items = this._visibleItems(col); return html`
      @@ -388,10 +660,10 @@ class MsmColumnBrowser extends LitElement {
      Loading\u2026
      ` : nothing} - ${!isLoading && col.items.length === 0 ? html` + ${!isLoading && items.length === 0 ? html`
      Empty folder
      ` : nothing} - ${!isLoading ? col.items.map( + ${!isLoading ? items.map( (item, idx) => this.renderItem(colIdx, item, idx), ) : nothing}
    diff --git a/tools/apps/msm/msm.css b/tools/apps/msm/msm.css index 90a2e41..60bf027 100644 --- a/tools/apps/msm/msm.css +++ b/tools/apps/msm/msm.css @@ -31,9 +31,16 @@ flex: 1; } +.msm-toolbar-role-badges { + display: flex; + align-items: center; + gap: 12px; + margin-top: 12px; + flex-wrap: wrap; +} + .role-badge { display: inline-block; - margin-top: 12px; font-size: var(--s2-body-xxs-size, 11px); font-weight: 600; text-transform: uppercase; @@ -44,6 +51,75 @@ color: var(--s2-blue-900); } +.role-badge.dual { + background: var(--s2-purple-100, #f5e8ff); + color: var(--s2-purple-900, #4b1d76); +} + +.hide-inherited-toggle { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 14px; + color: var(--s2-gray-900, #292929); + cursor: pointer; + user-select: none; + width: max-content; +} + +.hide-inherited-toggle input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + position: relative; + margin: 0; + width: 26px; + height: 14px; + border-radius: 7px; + background: var(--s2-gray-300, #d4d4d4); + cursor: pointer; + flex-shrink: 0; + transition: background-color 0.15s ease-in-out; +} + +.hide-inherited-toggle input[type="checkbox"]::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 10px; + height: 10px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgb(0 0 0 / 20%); + transition: transform 0.15s ease-in-out; +} + +.hide-inherited-toggle input[type="checkbox"]:checked { + background: var(--s2-blue-700, #393dba); +} + +.hide-inherited-toggle input[type="checkbox"]:checked::after { + transform: translateX(12px); +} + +.hide-inherited-toggle input[type="checkbox"]:hover:not(:disabled) { + background: var(--s2-gray-400, #b1b1b1); +} + +.hide-inherited-toggle input[type="checkbox"]:checked:hover:not(:disabled) { + background: var(--s2-blue-800, #2c3093); +} + +.hide-inherited-toggle input[type="checkbox"]:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; +} + +.hide-inherited-toggle input[type="checkbox"]:disabled { + opacity: 0.4; + cursor: default; +} + /* ===== Loading / Empty ===== */ .msm-loading, @@ -101,6 +177,36 @@ background: var(--s2-orange-100); } +.deep-link-warning, .site-warning { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.deep-link-warning .nx-alert-dismiss, .site-warning .nx-alert-dismiss { + appearance: none; + background: transparent; + border: none; + color: var(--s2-gray-700); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + flex-shrink: 0; +} + +.deep-link-warning .nx-alert-dismiss:hover { + background: rgb(0 0 0 / 5%); + color: var(--s2-gray-900); +} + +.deep-link-warning .nx-alert-dismiss:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; +} + /* ===== Layout ===== */ .msm-body { diff --git a/tools/apps/msm/msm.js b/tools/apps/msm/msm.js index 40f85bf..317965e 100644 --- a/tools/apps/msm/msm.js +++ b/tools/apps/msm/msm.js @@ -1,7 +1,12 @@ /* eslint-disable no-underscore-dangle, import/no-unresolved, no-console, class-methods-use-this */ import DA_SDK from 'https://da.live/nx/utils/sdk.js'; import { LitElement, html, nothing } from 'da-lit'; -import { fetchMsmConfig, checkPageOverrides } from './helpers/api.js'; +import { + fetchMsmConfig, + checkPageOverrides, + isActionableItem, + getSiteRoles, +} from './helpers/api.js'; import 'https://da.live/nx/public/sl/components.js'; import './helpers/column-browser.js'; import './helpers/action-panel.js'; @@ -21,15 +26,45 @@ try { console.warn('Failed to load styles:', e); } +const HIDE_INHERITED_KEY = 'da-msm-hide-inherited'; + +function loadHideInheritedPref() { + try { + return localStorage.getItem(HIDE_INHERITED_KEY) === 'true'; + } catch { + return false; + } +} + +function saveHideInheritedPref(value) { + try { + localStorage.setItem(HIDE_INHERITED_KEY, String(value)); + } catch { + /* localStorage may be unavailable in private mode; ignore */ + } +} + +function parseDeepLink() { + const params = new URLSearchParams(window.location.search); + const org = (params.get('org') || '').trim(); + if (!org) return null; + return { + org, + site: (params.get('site') || '').trim(), + path: (params.get('path') || '').trim(), + }; +} + class MsmApp extends LitElement { static properties = { context: { attribute: false }, token: { attribute: false }, + deepLink: { attribute: false }, _state: { state: true }, _org: { state: true }, _site: { state: true }, _role: { state: true }, - _baseSite: { state: true }, + _parentBase: { state: true }, _msmConfig: { state: true }, _selectedItems: { state: true }, _currentPath: { state: true }, @@ -37,6 +72,11 @@ class MsmApp extends LitElement { _pageOverrides: { state: true }, _initError: { state: true }, _siteWarning: { state: true }, + _parentChain: { state: true }, + _hasDescendants: { state: true }, + _hideInherited: { state: true }, + _deepLinkPath: { state: true }, + _deepLinkWarning: { state: true }, }; connectedCallback() { @@ -49,6 +89,51 @@ class MsmApp extends LitElement { this._initError = ''; this._role = 'base'; this._state = 'init'; + this._parentBase = ''; + this._parentChain = []; + this._hasDescendants = false; + this._hideInherited = loadHideInheritedPref(); + this._deepLinkPath = ''; + this._deepLinkWarning = ''; + + // Auto-load when a deep-link was supplied via URL query params. + if (this.deepLink?.org) { + this._org = this.deepLink.org; + this._site = this.deepLink.site || ''; + this._deepLinkPath = this.deepLink.path || ''; + this._state = 'loading'; + this.loadConfig(this.deepLink.org); + } + } + + handleDeepLinkConsumed() { + this._deepLinkPath = ''; + } + + handleDeepLinkWarning(e) { + const { requestedPath, lastResolvedPath } = e.detail || {}; + const tail = lastResolvedPath + ? ` (navigated as far as ${lastResolvedPath})` + : ''; + this._deepLinkWarning = `Could not resolve "${requestedPath}"${tail}.`; + } + + dismissSiteWarning() { + this._siteWarning = ''; + } + + dismissDeepLinkWarning() { + this._deepLinkWarning = ''; + } + + onHideInheritedToggle(checked) { + this._hideInherited = checked; + saveHideInheritedPref(checked); + } + + handleActionComplete() { + const cb = this.shadowRoot.querySelector('msm-column-browser'); + cb?.invalidateMergedCache(); } handleOrgSubmit(e) { @@ -71,37 +156,56 @@ class MsmApp extends LitElement { classifySite(config) { this._siteWarning = ''; + this._parentChain = []; + this._parentBase = ''; + this._hasDescendants = false; + this._satellites = {}; + this._browseSite = this._site; if (!this._site) { this._role = 'base'; - this._baseSite = ''; return; } - const isSatellite = config.baseSites.find( - (bs) => Object.keys(bs.satellites).includes(this._site), - ); + const roles = getSiteRoles(config, this._site); + const isBase = !!roles.asBase; + const isSatellite = !!roles.asSatellite; + if (isSatellite) { - this._role = 'satellite'; - this._baseSite = isSatellite.site; - this._satellites = { - [this._site]: isSatellite.satellites[this._site], - }; + this._parentBase = roles.asSatellite.base; + this._parentChain = roles.asSatellite.chain || []; + } + + if (isBase && isSatellite) { + // Middle-tier site: has children AND has a parent + this._role = 'dual'; + this._satellites = roles.asBase.satellites; + this._hasDescendants = Object.values(roles.asBase.satellites) + .some((s) => s.descendantCount > 0); return; } - const isBase = config.baseSites.find((bs) => bs.site === this._site); if (isBase) { this._role = 'base'; - this._baseSite = this._site; - this._satellites = isBase.satellites; + this._satellites = roles.asBase.satellites; + this._hasDescendants = Object.values(roles.asBase.satellites) + .some((s) => s.descendantCount > 0); + return; + } + + if (isSatellite) { + this._role = 'satellite'; + const leafEntry = config.baseSites + .find((bs) => Object.keys(bs.satellites).includes(this._site)) + ?.satellites[this._site]; + this._satellites = { [this._site]: leafEntry || { label: this._site } }; return; } const fallback = config.baseSites[0]; this._role = 'base'; - this._baseSite = fallback.site; this._satellites = fallback.satellites; + this._browseSite = fallback.site; this._siteWarning = `"${this._site}" is not a recognized base or satellite site. Showing "${fallback.site}" instead.`; } @@ -128,21 +232,44 @@ class MsmApp extends LitElement { this._currentPath = currentPath; this._currentSite = site; + if (this._role === 'satellite' || this._role === 'dual') { + const selfSite = this._role === 'satellite' ? this._site : site; + this._pageOverrides = this._buildSelfOverrides(selectedItems, selfSite); + } else { + this._pageOverrides = new Map(); + } + if (this._role === 'satellite') return; if (selectedItems.length > 0 && this._msmConfig) { - const baseSite = this._msmConfig.baseSites.find((s) => s.site === site); - if (baseSite) { - this._satellites = baseSite.satellites; + const roles = getSiteRoles(this._msmConfig, site); + if (roles.asBase) { + this._satellites = roles.asBase.satellites; + this._parentChain = roles.asSatellite?.chain || []; + this._parentBase = roles.asSatellite?.base || ''; + this._hasDescendants = Object.values(roles.asBase.satellites) + .some((s) => s.descendantCount > 0); this.loadOverrides(selectedItems); } - } else { - this._pageOverrides = new Map(); } } + _buildSelfOverrides(selectedItems, selfSite) { + const map = new Map(); + selectedItems.filter(isActionableItem).forEach((page) => { + map.set(page.path, [{ + site: selfSite, + label: selfSite, + hasOverride: !page.inheritedFrom, + inheritedFrom: page.inheritedFrom || null, + sourceSite: page.sourceSite || null, + }]); + }); + return map; + } + async loadOverrides(items) { - const pages = items.filter((i) => i.ext === 'html'); + const pages = items.filter((i) => isActionableItem(i)); if (!pages.length) return; const org = this._org; @@ -150,16 +277,27 @@ class MsmApp extends LitElement { const overrides = new Map(); await Promise.all(pages.map(async (page) => { - const pagePath = page.path.replace('.html', ''); - const results = await checkPageOverrides(org, sats, pagePath); + const ext = page.ext || 'html'; + const pagePath = page.path.replace(/\.[^/.]+$/, ''); + const results = await checkPageOverrides(org, sats, pagePath, ext); overrides.set(page.path, results); })); - this._pageOverrides = new Map(overrides); + const merged = new Map(); + const allPaths = new Set([ + ...Array.from(this._pageOverrides?.keys?.() || []), + ...overrides.keys(), + ]); + allPaths.forEach((p) => { + const selfEntries = this._pageOverrides?.get(p) || []; + const childEntries = overrides.get(p) || []; + merged.set(p, [...selfEntries, ...childEntries]); + }); + this._pageOverrides = merged; } get _selectedPages() { - return this._selectedItems.filter((i) => i.ext === 'html'); + return this._selectedItems.filter((i) => isActionableItem(i)); } get _isSinglePage() { @@ -172,6 +310,7 @@ class MsmApp extends LitElement { } renderToolbar() { + const canShowInheritedToggle = this._role === 'satellite' || this._role === 'dual'; return html`

    Multi-Site Management

    @@ -188,7 +327,21 @@ class MsmApp extends LitElement { > Load - ${this._role === 'satellite' ? html`Satellite` : nothing} +
    + ${this._role === 'satellite' ? html`Satellite` : nothing} + ${this._role === 'dual' ? html`Middle-tier` : nothing} + ${canShowInheritedToggle ? html` + + ` : nothing} +
    `; } @@ -214,25 +367,48 @@ class MsmApp extends LitElement { } return html` - ${this._siteWarning ? html`

    ${this._siteWarning}

    ` : nothing} + ${this._siteWarning ? html` +
    +

    ${this._siteWarning}

    + +
    + ` : nothing} + ${this._deepLinkWarning ? html` + + ` : nothing}
    ${this._selectedPages.length > 0 ? html` ` : nothing}
    @@ -250,9 +426,11 @@ class MsmApp extends LitElement { customElements.define('msm-app', MsmApp); (async function init() { + const deepLink = parseDeepLink(); const { context, token } = await DA_SDK; const cmp = document.createElement('msm-app'); cmp.context = context; cmp.token = token; + if (deepLink) cmp.deepLink = deepLink; document.body.append(cmp); }()); diff --git a/tools/plugins/msm/README.md b/tools/plugins/msm/README.md new file mode 100644 index 0000000..1ef128a --- /dev/null +++ b/tools/plugins/msm/README.md @@ -0,0 +1,125 @@ +# Multi-site Manager (MSM) Plugin + +A DA Prepare-menu plugin for managing multi-site inheritance from the page editor. +Lets authors roll out, sync, override, and resume inheritance between a base site +and its satellite sites without leaving DA. + +This plugin is a standalone fork of the OOTB `Multi-site Manager` action that +ships in [`da-live`](https://github.com/adobe/da-live/tree/main/blocks/edit/da-prepare/actions/msm). +The OOTB version is still available by default; sites that want to opt in to +the plugin (e.g. to pin a specific version, or to use the plugin URL from any +site config without copying code) can configure their `prepare` sheet as +described below. + +## How it works + +1. Authors open a page in DA, click the Prepare menu, and pick **Multi-site Manager**. +2. The plugin reads the org's `msm` config sheet to determine the page's role: + - **As a base** — lists satellites that inherit from this site. + - **As a satellite** — shows the inheritance chain up to the base. + - **Dual role** — both, with a "Sync from parent" switch to flip direction. +3. The author picks an action (roll out to preview/live, cancel inheritance, + sync from base, resume inheritance, etc.) and the plugin executes it via + the DA Admin and AEM Admin APIs. + +The plugin uses [`DA_SDK`](https://da.live/nx/utils/sdk.js) so all admin calls +are authenticated against the host page's signed-in user — no separate auth +is required. + +## Configuration + +### 1. `.da/config.json` — `msm` sheet + +The plugin reads the org-level `msm` sheet to discover the base/satellite +relationships. Each row describes one site: + +| base | satellite | title | +| ------ | ----------- | --------------- | +| mccs | | MCCS Global | +| mccs | san-diego | San Diego | +| mccs | pendleton | Camp Pendleton | + +- A row with `base` set and `satellite` empty defines the **base label** for + that site (used in the breadcrumb). +- A row with both `base` and `satellite` defines an inheritance edge: edits + to the base flow to the satellite unless the satellite has a local override. +- Multi-level inheritance works: a `satellite` row can also appear as a + `base` row pointing to deeper satellites. + +### 2. `.da/config.json` — `prepare` sheet + +To make the plugin appear in the Prepare menu, add a row to the `prepare` +sheet at either the org or site level. Either point at this repo's hosted +plugin (no code copy needed) or use a relative path if you've vendored the +plugin into your own repo: + +**Option A: use the hosted plugin (recommended)** + +| title | path | icon | experience | +| ------------------ | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------- | +| Multi-site Manager | `https://main--da-blog-tools--aemsites.aem.live/tools/plugins/msm/msm.html` | `https://da.live/blocks/edit/img/S2_Icon_GlobeGrid_20_N.svg#S2_Icon_GlobeGrid` | dialog | + +**Option B: vendored copy in your repo** + +| title | path | icon | experience | +| ------------------ | --------------------------------- | --------------------------------------------------------------------- | ---------- | +| Multi-site Manager | `/tools/plugins/msm/msm.html` | `https://da.live/blocks/edit/img/S2_Icon_GlobeGrid_20_N.svg#S2_Icon_GlobeGrid` | dialog | + +> The DA Prepare menu merges items by `title`, so using `Multi-site Manager` +> as the title overrides the OOTB version when this row is present. + +## Behavior matrix + +| Page role | Direction available | Actions | +| ---------------- | ----------------------- | ------------------------------------------------------------------------- | +| Base only | Downward (children) | Roll out to preview · Roll out to live · Cancel inheritance · Sync · Resume inheritance | +| Satellite only | Upward (parent) | Sync from base · Resume inheritance | +| Both | Toggleable via switch | Both sets, scoped by the switch position | + +### Sync modes + +When syncing content from a base to a satellite (or vice versa), two modes are +available: + +- **Merge** — runs a 3-way merge that preserves local edits in the satellite + while pulling in changes from the base. Backed by the + `mergeCopy` function from [`nx/blocks/loc/project`](https://da.live/nx/blocks/loc/project/index.js), + loaded dynamically at runtime. +- **Override** — replaces the satellite's content with the base's content. + Local edits are lost. + +### Cascade to nested sites + +For recursive actions (Roll out to preview / live) on sites that have nested +descendants, an extra checkbox appears in the footer: +**"Cascade to nested sites (+N more)"**. When checked, the action runs against +the entire subtree below each selected satellite, not just the direct child. + +## Relationship to the OOTB version + +This plugin is a **fork-copy** of `blocks/edit/da-prepare/actions/msm/` in +da-live. The component itself (``) and its CSS are the same; only +the dependency wiring differs: + +| Concern | OOTB (da-live) | Plugin (this repo) | +| ------------------ | ----------------------------------------------- | ------------------------------------------------------------------ | +| Lit | `da-lit` resolved internally | `da-lit` resolved via importmap → `/tools/deps/lit/dist/index.js` | +| `daFetch` | `blocks/shared/utils.js` | `DA_SDK.actions.daFetch`, plumbed in via `setSdkFetch` | +| `DA_ORIGIN` | `blocks/shared/constants.js` | `https://da.live/nx/public/utils/constants.js` | +| NX URL | `getNx()` (versioned/branch-aware) | Hardcoded `https://da.live/nx` | +| `mergeCopy` | Dynamic import via `getNx()` | Dynamic import via the hardcoded NX URL | +| UI primitives | `se-*` components from `${nx}/public/se/components.js` | `sl-*` components from `${NX}/public/sl/components.js`, styled via nexter + `sl/styles.css` + `buttons.css` (loaded with `loadStyle` / `getStyle`) | +| Icons | Relative paths `/blocks/edit/img/...` | Absolute URLs `https://da.live/blocks/edit/img/...` | +| Edit-link origin | `window.location.origin` (always da.live) | Derived from `document.referrer`; falls back to `https://da.live` | + +## Files + +``` +tools/plugins/msm/ +├── README.md (this file) +├── msm.html (iframe entry; loads da-lit via importmap and msm.js) +├── msm.js ( Lit component + self-init from DA_SDK) +├── msm.css (component styles, adapted to fill the 400x400 iframe) +├── config.js (org/site msm-config resolution, override checks) +└── utils.js (preview/publish/override/merge AEM+DA admin calls) +``` diff --git a/tools/plugins/msm/config.js b/tools/plugins/msm/config.js new file mode 100644 index 0000000..01dd003 --- /dev/null +++ b/tools/plugins/msm/config.js @@ -0,0 +1,180 @@ +/* eslint-disable import/no-unresolved */ +import { DA_ORIGIN } from 'https://da.live/nx/public/utils/constants.js'; + +// SDK plumbing. See utils.js — both helper modules need the host-supplied +// daFetch so they can authenticate against admin.da.live without their own +// IMS state. Each module keeps its own reference; the plugin entry calls +// `setSdkFetch` on both during init. +let sdkFetch; +export function setSdkFetch(fn) { sdkFetch = fn; } +function daFetch(url, opts) { + if (!sdkFetch) throw new Error('MSM plugin: SDK daFetch not initialised'); + return sdkFetch(url, opts); +} + +const configCache = {}; +const orgConfigPromises = {}; + +// Mirrors fetchDaConfigs({ org }) from da-live's shared/utils.js — fetches +// /config// once, caches the promise, returns the parsed JSON (or null). +function fetchOrgConfig(org) { + if (!org) return Promise.resolve(null); + orgConfigPromises[org] ??= (async () => { + const url = `${DA_ORIGIN}/config/${org}/`; + const resp = await daFetch(url); + if (!resp.ok) return null; + const json = await resp.json(); + return json; + })(); + return orgConfigPromises[org]; +} + +async function fetchOrgMsmRows(org) { + const orgConfig = await fetchOrgConfig(org); + const rows = orgConfig?.msm?.data || []; + return rows; +} + +function getDirectChildren(rows, site) { + return rows + .filter((row) => row.base === site && row.satellite) + .map((row) => ({ site: row.satellite, label: row.title || row.satellite })); +} + +function getParentRow(rows, site) { + return rows.find((row) => row.satellite === site); +} + +function getBaseLabel(rows, site) { + const labelRow = rows.find((row) => row.base === site && !row.satellite); + return labelRow?.title; +} + +function walkSubtree(rows, rootSite, visited = new Set()) { + if (visited.has(rootSite)) return []; + visited.add(rootSite); + const children = getDirectChildren(rows, rootSite); + return children.flatMap((child) => [ + child, + ...walkSubtree(rows, child.site, visited), + ]); +} + +function walkChain(rows, site, visited = new Set()) { + const chain = []; + let current = site; + while (current && !visited.has(current)) { + visited.add(current); + const parentRow = getParentRow(rows, current); + if (!parentRow) break; + const baseLabel = getBaseLabel(rows, parentRow.base) || parentRow.base; + chain.unshift({ site: parentRow.base, label: baseLabel }); + current = parentRow.base; + } + return chain; +} + +function resolveConfig(rows, site) { + if (!rows.length || rows[0].base === undefined) return null; + + const directChildren = getDirectChildren(rows, site); + const parentRow = getParentRow(rows, site); + + if (!directChildren.length && !parentRow) return null; + + const result = {}; + + if (directChildren.length) { + const satellites = directChildren.reduce((acc, child) => { + const subtree = walkSubtree(rows, child.site); + acc[child.site] = { + label: child.label, + descendantCount: subtree.length, + }; + return acc; + }, {}); + result.asBase = { + baseLabel: getBaseLabel(rows, site), + satellites, + }; + } + + if (parentRow) { + const chain = walkChain(rows, site); + result.asSatellite = { + base: parentRow.base, + baseLabel: getBaseLabel(rows, parentRow.base) || parentRow.base, + chain, + }; + } + + return result; +} + +async function fetchSiteConfig(org, site) { + const key = `${org}/${site}`; + if (configCache[key]) return configCache[key]; + + const rows = await fetchOrgMsmRows(org); + if (!rows.length) { + return null; + } + + const config = resolveConfig(rows, site); + if (!config) { + return null; + } + + configCache[key] = { config, rows }; + return configCache[key]; +} + +export async function getSiteConfig(org, site) { + const entry = await fetchSiteConfig(org, site); + return entry?.config || null; +} + +export async function getSubtreeSatellites(org, baseSite) { + const entry = await fetchSiteConfig(org, baseSite); + if (!entry) return []; + return walkSubtree(entry.rows, baseSite); +} + +export async function getSatellites(org, baseSite) { + const config = await getSiteConfig(org, baseSite); + return config?.asBase?.satellites || {}; +} + +export async function getBaseSite(org, satellite) { + const config = await getSiteConfig(org, satellite); + return config?.asSatellite?.base || null; +} + +export async function isPageLocal(org, site, pagePath) { + const resp = await daFetch( + `${DA_ORIGIN}/source/${org}/${site}${pagePath}.html`, + { method: 'HEAD' }, + ); + return resp.ok; +} + +export async function checkOverrides(org, satellites, pagePath) { + const entries = Object.entries(satellites); + const results = await Promise.all( + entries.map(async ([site, info]) => { + const local = await isPageLocal(org, site, pagePath); + return { + site, + label: info.label, + descendantCount: info.descendantCount || 0, + hasOverride: local, + }; + }), + ); + return results; +} + +export function clearMsmCache() { + Object.keys(configCache).forEach((key) => { delete configCache[key]; }); + Object.keys(orgConfigPromises).forEach((key) => { delete orgConfigPromises[key]; }); +} diff --git a/tools/plugins/msm/msm.css b/tools/plugins/msm/msm.css new file mode 100644 index 0000000..c7cacbc --- /dev/null +++ b/tools/plugins/msm/msm.css @@ -0,0 +1,430 @@ +/* + * MSM plugin styles + * + * Mirrors the styles from da-live's blocks/edit/da-prepare/actions/msm/msm.css + * with one adaptation for the plugin context: the component fills the host + * iframe (which da-prepare locks to 400x400) rather than sizing itself to + * 420px the way the in-process OOTB version does. + */ + +:host { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; + min-height: 100%; + padding: 0 12px 12px; + box-sizing: border-box; + + p { margin: 0; } +} + +/* --- Loading / empty states --- */ + +.loading, +.no-satellites { + font-size: 14px; + font-style: italic; + color: var(--s2-gray-600, #717171); +} + +/* --- Inheritance breadcrumb (static) --- */ + +.crumb-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--s2-gray-700, #4b4b4b); +} + +.crumb-label { + color: var(--s2-gray-600, #717171); + margin-right: 4px; +} + +.crumb-node { + color: var(--s2-gray-800, #292929); +} + +.crumb-node.current { + color: var(--s2-blue-700, #393dba); + font-weight: 600; +} + +.crumb-sep { + color: var(--s2-gray-400, #b1b1b1); +} + +/* --- Direction switch (Spectrum 2 Switch, size M) --- */ + +.direction-switch { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 14px; + color: var(--s2-gray-900, #292929); + cursor: pointer; + user-select: none; + width: max-content; +} + +.direction-switch input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + position: relative; + margin: 0; + width: 26px; + height: 14px; + border-radius: 7px; + background: var(--s2-gray-300, #d4d4d4); + cursor: pointer; + flex-shrink: 0; + transition: background-color 0.15s ease-in-out; +} + +.direction-switch input[type="checkbox"]::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 10px; + height: 10px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgb(0 0 0 / 0.2); + transition: transform 0.15s ease-in-out; +} + +.direction-switch input[type="checkbox"]:checked { + background: var(--s2-blue-700, #393dba); +} + +.direction-switch input[type="checkbox"]:checked::after { + transform: translateX(12px); +} + +.direction-switch input[type="checkbox"]:hover:not(:disabled) { + background: var(--s2-gray-400, #b1b1b1); +} + +.direction-switch input[type="checkbox"]:checked:hover:not(:disabled) { + background: var(--s2-blue-800, #2c3093); +} + +.direction-switch input[type="checkbox"]:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; +} + +.direction-switch input[type="checkbox"]:disabled { + opacity: 0.4; + cursor: default; +} + +.descendant-badge { + display: inline-block; + margin-left: 6px; + padding: 0 6px; + font-size: 10px; + font-weight: 600; + color: var(--s2-gray-700, #4b4b4b); + background: var(--s2-gray-200, #e1e1e1); + border-radius: 8px; + vertical-align: middle; +} + +/* --- Action row --- */ + +.action-row { + display: grid; + /* Always two equal columns — the action picker stays at 50% width whether + or not the sync-mode picker is visible, so the layout stays stable. */ + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.action-row se-select { + width: 100%; + min-width: 0; +} + +/* --- Upward summary (Source / Target / Override status) --- */ + +.upward-summary { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; +} + +.upward-summary .row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 20px; +} + +.upward-summary .label { + color: var(--s2-gray-600, #717171); +} + +.upward-summary .value { + font-weight: 600; + color: var(--s2-gray-900, #292929); + display: inline-flex; + align-items: center; + gap: 6px; +} + +.upward-summary .value.muted { + font-weight: 500; + color: var(--s2-gray-600, #717171); +} + +/* --- Children list (two-column: Inherited | Custom) --- */ + +.satellite-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.satellite-column { + display: flex; + flex-direction: column; + min-width: 0; +} + +.column-heading { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--s2-gray-600, #717171); + padding-bottom: 4px; + border-bottom: 1px solid var(--s2-gray-200, #e1e1e1); + margin: 0 0 4px; +} + +.satellite-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + max-height: 240px; + overflow-y: auto; + + &::-webkit-scrollbar { width: 5px; } + &::-webkit-scrollbar-track { background: transparent; } + &::-webkit-scrollbar-thumb { + background: var(--s2-gray-300, #d4d4d4); + border-radius: 3px; + &:hover { background: var(--s2-gray-500, #929292); } + } +} + +.sat-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 4px; + border-radius: 4px; + transition: background-color 0.1s; + + &:hover:not(.out-of-scope) { background: var(--s2-gray-50, #f8f8f8); } + + &.out-of-scope { + opacity: 0.38; + } + + label { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + cursor: pointer; + font-size: 14px; + color: var(--s2-gray-900, #292929); + } + + label span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +/* --- S2 checkbox (matches spectrum-two tokens) --- */ + +.sat-row input[type="checkbox"], +.footer-cascade input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + margin: 0; + border: 2px solid var(--s2-gray-600, #717171); + border-radius: 2px; + cursor: pointer; + flex-shrink: 0; + position: relative; + transition: border 0.13s ease-in-out; + + &:checked { + border-color: var(--s2-gray-800, #292929); + border-width: 7px; + background: var(--s2-gray-50, #f8f8f8); + + &::after { + content: ''; + position: absolute; + inset: 0; + top: -2px; + left: -3px; + width: 4px; + height: 8px; + margin: auto; + border: solid var(--s2-gray-50, #f8f8f8); + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + } + + &:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; + } + + &:disabled { opacity: 0.4; cursor: default; } + + &:hover:not(:disabled) { + border-color: var(--s2-gray-700, #4b4b4b); + } + + &:checked:hover:not(:disabled) { + border-color: var(--s2-gray-800, #292929); + } +} + +/* --- Status icons --- */ + +.result-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.result-icon.success { color: #0d6e31; } +.result-icon.error { color: var(--s2-red-900, #d31510); } +.result-icon.pending { + color: var(--s2-gray-600, #717171); + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* --- Icon button (open-in-editor) --- */ + +.icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; + border: none; + border-radius: 4px; + background: none; + color: var(--s2-gray-500, #929292); + cursor: pointer; + padding: 0; + text-decoration: none; + transition: background-color 0.15s, color 0.15s; + + svg { width: 14px; height: 14px; fill: currentColor; } + &:hover { + background: var(--s2-gray-200, #e1e1e1); + color: var(--s2-gray-900, #292929); + } +} + +/* --- Footer (single, shared) --- */ + +.form-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.footer-cascade { + margin-right: auto; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--s2-gray-800, #292929); + cursor: pointer; + user-select: none; +} + +/* --- Confirm dialog (yellow caution box) --- */ + +.confirm-box { + padding: 12px; + background: var(--s2-yellow-100, #fef9ee); + border: 1px solid var(--s2-yellow-300, #f0dca0); + border-radius: 8px; + font-size: 14px; + color: var(--s2-gray-900, #292929); + + p { margin: 0 0 10px; line-height: 1.4; } + + .confirm-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + } +} + +.confirm-btn { + appearance: none; + border: 1px solid var(--s2-gray-400, #d1d1d1); + border-radius: 4px; + background: #fff; + color: var(--s2-gray-800, #3e3e3e); + font-size: 13px; + font-weight: 500; + font-family: inherit; + padding: 5px 12px; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.15s, border-color 0.15s; + + &:hover { + background: var(--s2-gray-100, #f5f5f5); + border-color: var(--s2-gray-500, #929292); + } + + &:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; + } + + &.danger { + color: var(--s2-red-900, #d31510); + border-color: #f0c8c2; + &:hover { + background: #fef0ee; + border-color: var(--s2-red-900, #d31510); + } + } +} diff --git a/tools/plugins/msm/msm.html b/tools/plugins/msm/msm.html new file mode 100644 index 0000000..2181645 --- /dev/null +++ b/tools/plugins/msm/msm.html @@ -0,0 +1,27 @@ + + + + + + Multi-site Manager + + + + + + + + diff --git a/tools/plugins/msm/msm.js b/tools/plugins/msm/msm.js new file mode 100644 index 0000000..e21e697 --- /dev/null +++ b/tools/plugins/msm/msm.js @@ -0,0 +1,752 @@ +/* eslint-disable no-underscore-dangle, import/no-unresolved, no-console */ +/* eslint-disable class-methods-use-this */ +import { LitElement, html, nothing } from 'da-lit'; +import DA_SDK from 'https://da.live/nx/utils/sdk.js'; +import { + getSiteConfig, + getSubtreeSatellites, + isPageLocal, + checkOverrides, + setSdkFetch as setConfigSdkFetch, +} from './config.js'; +import { + previewSatellite, + publishSatellite, + createOverride, + deleteOverride, + mergeFromBase, + getSatellitePageStatus, + setSdkFetch as setUtilsSdkFetch, + setEditUrlOrigin, +} from './utils.js'; + +const STATUS = { pending: 'pending', success: 'success', error: 'error' }; +const SYNC_MODE = { override: 'override', merge: 'merge' }; + +const RECURSIVE_ACTIONS = new Set(['preview', 'publish']); +const SYNC_ACTIONS = new Set(['sync', 'sync-from-base']); +const UPWARD_ACTIONS = new Set(['sync-from-base', 'resume-inheritance']); + +const ACTION_SCOPE = { + preview: 'inherited', + publish: 'inherited', + break: 'inherited', + sync: 'custom', + reset: 'custom', +}; + +// Icons are referenced as absolute URLs to da.live so they resolve correctly +// even though the plugin is served from a different origin (e.g. da-blog-tools). +const ICON_BASE = 'https://da.live/blocks/edit/img'; + +// Component / style pipeline. We pull two element families to mirror OOTB: +// • sl-* — older "Super Lite" controls; the Apply button uses , +// loaded from da.live's nx CDN. +// • se-* — newer "Spectrum Express" controls; the action picker uses +// , which renders a fully custom dropdown that matches +// the OOTB MSM Prepare menu. Vendored in ./vendor/se/ because +// se hasn't shipped to any public da.live path yet, and the +// plugin can be loaded into an HTTPS shell that can't fall back +// to http://localhost:6456 (mixed-content blocked). +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('[MSM Plugin] Failed to load sl/nexter styles:', e); +} +try { + await import('./vendor/se/components.js'); +} catch (e) { + console.warn(`[MSM Plugin] Failed to load vendored se components: ${e.message}. ` + + 'Falling back to native this.handleToggle(sat.site)} /> + ${sat.label} + ${showDescendantBadge ? html` + + +${sat.descendantCount} + + ` : nothing} + + ${this.renderStatusIcon(sat.status)} + ${sat.hasOverride ? html` + + + ` : nothing} + `; + } + + renderConfirm() { + if (!this._confirmAction) return nothing; + return html` +
    +

    ${this._confirmAction.message}

    +
    + + +
    +
    `; + } + + renderSyncModeSelect() { + return html` + { this._syncMode = e.target.value; }}> + + + `; + } + + renderActionPicker() { + const groups = []; + + // The picker shows only the optgroups relevant to the active direction. + // The Sync-from-parent switch (or absence of one of the roles) decides + // which direction is active. + if (this._asSatellite && this._isUpwardMode) { + groups.push({ + label: 'From parent', + items: [ + { value: 'sync-from-base', label: 'Sync from base' }, + { value: 'resume-inheritance', label: 'Resume inheritance' }, + ], + }); + } + + if (this._asBase && !this._isUpwardMode) { + groups.push({ + label: 'Inherited sites', + items: [ + { value: 'preview', label: 'Roll out to preview' }, + { value: 'publish', label: 'Roll out to live' }, + { value: 'break', label: 'Cancel inheritance' }, + ], + }); + groups.push({ + label: 'Custom sites', + items: [ + { value: 'sync', label: 'Sync to satellite' }, + { value: 'reset', label: 'Resume inheritance' }, + ], + }); + } + + return html` + this.onActionChange(e.target.value)}> + ${groups.map((group) => html` + + ${group.items.map((opt) => html` + + `)} + + `)} + `; + } + + renderDirectionSwitch() { + if (!this._hasDualRole) return nothing; + return html` + `; + } + + renderBreadcrumb() { + if (!this._asSatellite) return nothing; + + const chain = [ + ...this._asSatellite.chain, + { site: this.details.site, label: this.details.site, current: true }, + ]; + + return html` +
    + Inherits from + ${chain.map((node, idx) => html` + ${idx > 0 ? html`` : nothing} + ${node.label} + `)} +
    `; + } + + renderUpwardSummary() { + // Skip for base-only pages or when a downward action is selected. + if (!this._asSatellite) return nothing; + if (!this._isUpwardMode) return nothing; + + const baseLabel = this._asSatellite.baseLabel || this._asSatellite.base; + const targetLabel = this.details.site; + const overrideText = this._hasOverride ? 'Yes — overridden' : 'None — inherited'; + const overrideMuted = !this._hasOverride; + + return html` +
    +
    + Source + ${baseLabel} +
    +
    + Target + ${targetLabel} ${this.renderStatusIcon(this._satStatus)} +
    +
    + Local override + ${overrideText} +
    +
    `; + } + + renderChildrenList() { + // The children list only applies to the downward direction. When the + // Sync-from-parent switch is on (or the page is satellite-only), hide + // the list entirely so the dialog focuses on the upward action. + if (!this._asBase || this._isUpwardMode) return nothing; + const inherited = this._inherited; + const custom = this._custom; + if (!inherited.length && !custom.length) return nothing; + + return html` +
    + ${inherited.length ? html` +
    +

    Inherited

    +
      + ${inherited.map((sat) => this.renderSatellite(sat))} +
    +
    ` : nothing} + ${custom.length ? html` +
    +

    Custom

    +
      + ${custom.map((sat) => this.renderSatellite(sat))} +
    +
    ` : nothing} +
    `; + } + + renderFooter() { + const showCascade = this._isRecursiveActive + && this._totalDescendants > 0 + && !this._isUpwardMode; + // "Resume inheritance" on a satellite is a no-op if there's no local + // override to remove; disable Apply in that case. + const noOverrideToResume = this._action === 'resume-inheritance' + && this._asSatellite && !this._hasOverride; + const applyDisabled = this._busy + || (this._isUpwardMode ? noOverrideToResume : !this._canApplyDownward); + + return html` +
    + ${showCascade ? html` + ` : nothing} + this.apply()} + ?disabled=${applyDisabled}>Apply +
    `; + } + + render() { + if (this._loading) { + return html`

    ${this._loading}

    `; + } + + if (!this._asBase && !this._asSatellite) { + return html`

    No satellite sites configured.

    `; + } + + return html` + ${this.renderBreadcrumb()} + ${this.renderDirectionSwitch()} +
    + ${this.renderActionPicker()} + ${this._isSyncMode ? this.renderSyncModeSelect() : nothing} +
    + ${this.renderUpwardSummary()} + ${this.renderChildrenList()} + ${this.renderFooter()} + ${this.renderConfirm()}`; + } +} + +customElements.define('da-msm', DaMsm); + +// Default export keeps parity with the OOTB da-live entry point in case +// another host wants to embed the component without going through the +// self-initialising iframe flow. +export default function render(details) { + const cmp = document.createElement('da-msm'); + cmp.details = details; + return cmp; +} + +/** + * Self-initialising entry. Runs when msm.js is loaded directly by msm.html + * inside the da-prepare iframe. Subscribes to DA_SDK, wires the host's + * `actions.daFetch` into our helpers, derives the parent origin for edit + * URLs, then mounts the component. + */ +(async function initAsDialog() { + if (typeof window === 'undefined' || !document.body) return; + + try { + const { context, actions } = await DA_SDK; + // The DA Prepare host posts `context = { view, org, site, ref, path }`. + // Fall back to `repo` for hosts that still use the older field name. + const { org, path } = context; + const site = context.site || context.repo; + console.log('[MSM Plugin] Init context:', { org, site, path }); + console.log('[MSM Plugin] actions.daFetch available?', typeof actions?.daFetch); + + // Wire host-supplied daFetch into both helper modules. + setConfigSdkFetch(actions.daFetch); + setUtilsSdkFetch(actions.daFetch); + + // Derive the edit-URL origin from the parent (e.g. https://da.live or + // https://da.page). Falls back to da.live if document.referrer is empty. + if (document.referrer) { + try { + setEditUrlOrigin(new URL(document.referrer).origin); + } catch { + /* keep default */ + } + } + + const cmp = document.createElement('da-msm'); + cmp.details = { org, site, path }; + document.body.append(cmp); + } catch (error) { + console.error('[MSM Plugin] Initialization error:', error); + const pre = document.createElement('pre'); + pre.style.cssText = 'padding:12px;color:#d31510;font-family:monospace;font-size:12px;'; + pre.textContent = `Failed to initialise MSM plugin: ${error.message}`; + document.body.append(pre); + } +}()); diff --git a/tools/plugins/msm/utils.js b/tools/plugins/msm/utils.js new file mode 100644 index 0000000..eef7992 --- /dev/null +++ b/tools/plugins/msm/utils.js @@ -0,0 +1,110 @@ +/* eslint-disable import/no-unresolved */ +import { DA_ORIGIN } from 'https://da.live/nx/public/utils/constants.js'; + +const AEM_ADMIN = 'https://admin.hlx.page'; + +// NX origin used to dynamically load the `mergeCopy` function. Kept as a +// runtime constant so the plugin can be repointed at a different NX build +// (e.g. for staging) without code changes elsewhere. +const NX = 'https://da.live/nx'; + +// SDK plumbing. The plugin runs inside an iframe served from a different +// origin than da.live, so it can't use IMS-backed fetch directly. The host +// page hands us `actions.daFetch` via DA_SDK and we route every authenticated +// request through it. `setSdkFetch` is called once during plugin init. +let sdkFetch; +export function setSdkFetch(fn) { sdkFetch = fn; } +function daFetch(url, opts) { + if (!sdkFetch) throw new Error('MSM plugin: SDK daFetch not initialised'); + return sdkFetch(url, opts); +} + +export async function previewSatellite(org, satellite, pagePath) { + const aemPath = pagePath.replace('.html', ''); + const url = `${AEM_ADMIN}/preview/${org}/${satellite}/main${aemPath}`; + const resp = await daFetch(url, { method: 'POST' }); + if (!resp.ok) { + const xError = resp.headers?.get('x-error') || `Preview failed (${resp.status})`; + return { error: xError }; + } + return resp.json(); +} + +export async function publishSatellite(org, satellite, pagePath) { + const aemPath = pagePath.replace('.html', ''); + const url = `${AEM_ADMIN}/live/${org}/${satellite}/main${aemPath}`; + const resp = await daFetch(url, { method: 'POST' }); + if (!resp.ok) { + const xError = resp.headers?.get('x-error') || `Publish failed (${resp.status})`; + return { error: xError }; + } + return resp.json(); +} + +export async function createOverride(org, base, satellite, pagePath) { + const basePath = `${DA_ORIGIN}/source/${org}/${base}${pagePath}.html`; + const resp = await daFetch(basePath); + if (!resp.ok) return { error: `Failed to fetch base content (${resp.status})` }; + + const html = await resp.text(); + const blob = new Blob([html], { type: 'text/html' }); + const formData = new FormData(); + formData.append('data', blob); + + const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.html`; + const saveResp = await daFetch(satPath, { method: 'PUT', body: formData }); + if (!saveResp.ok) return { error: `Failed to create override (${saveResp.status})` }; + return { ok: true }; +} + +export async function getSatellitePageStatus(org, satellite, pagePath) { + const aemPath = pagePath.replace('.html', ''); + const url = `${AEM_ADMIN}/status/${org}/${satellite}/main${aemPath}`; + const resp = await daFetch(url); + if (!resp.ok) return { preview: false, live: false }; + const json = await resp.json(); + return { + preview: json.preview?.status === 200, + live: json.live?.status === 200, + }; +} + +export async function deleteOverride(org, satellite, pagePath) { + const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.html`; + const resp = await daFetch(satPath, { method: 'DELETE' }); + if (!resp.ok) return { error: `Failed to delete override (${resp.status})` }; + return { ok: true }; +} + +let mergeCopyFn; +export function setMergeCopy(fn) { mergeCopyFn = fn; } + +// `editUrlOrigin` is the origin used to build the in-editor deep link returned +// to the UI. In the OOTB da-live build it was `window.location.origin`, but +// the plugin's iframe is served from a different origin, so the caller (or +// init code) sets this to the actual da.live host the user is editing on. +let editUrlOrigin = 'https://da.live'; +export function setEditUrlOrigin(origin) { + if (origin) editUrlOrigin = origin; +} +export function getEditUrlOrigin() { return editUrlOrigin; } + +export async function mergeFromBase(org, base, satellite, pagePath) { + try { + const mergeCopy = mergeCopyFn + || (await import(`${NX}/blocks/loc/project/index.js`)).mergeCopy; + + const url = { + source: `/${org}/${base}${pagePath}.html`, + destination: `/${org}/${satellite}${pagePath}.html`, + }; + + const result = await mergeCopy(url, 'MSM Merge'); + if (!result?.ok) return { error: 'Merge failed' }; + + const editUrl = `${editUrlOrigin}/edit#/${org}/${satellite}${pagePath}`; + return { ok: true, editUrl }; + } catch (e) { + return { error: e.message || 'Merge failed' }; + } +} diff --git a/tools/plugins/msm/vendor/se/components.css b/tools/plugins/msm/vendor/se/components.css new file mode 100644 index 0000000..6e04859 --- /dev/null +++ b/tools/plugins/msm/vendor/se/components.css @@ -0,0 +1,470 @@ +/* stylelint-disable */ +/* + * Vendored snapshot of da-nx's nx2/public/se/components.css + * (https://github.com/adobe/da-nx, ravuthu/se-select branch). + * + * The :host { --s2-* } block below provides fallbacks for the design tokens + * that upstream relies on being set by nx2/public/se/styles.css. We don't + * load that global stylesheet, so the values are pinned here so se-select + * renders correctly regardless of host environment. If the host page does + * set these vars, they win via normal CSS inheritance. + * + * To refresh: re-copy from da-nx and re-apply the :host token block. + */ + +:host { + /* Design tokens (mirrors da-nx nx2/styles/styles.css) */ + --s2-gray-50: #f8f8f8; + --s2-gray-75: #f3f3f3; + --s2-gray-100: #e9e9e9; + --s2-gray-200: #e1e1e1; + --s2-gray-300: #dadada; + --s2-gray-400: #c6c6c6; + --s2-gray-500: #8f8f8f; + --s2-gray-600: #717171; + --s2-gray-700: #505050; + --s2-gray-800: #292929; + --s2-gray-900: #131313; + --s2-blue-900: #3b63fb; + --s2-blue-1000: #274dea; + --s2-red-200: #ffebe8; + --s2-red-800: #ea3829; + --s2-red-900: #d73220; + --s2-font-family: "Adobe Clean", adobe-clean, "Trebuchet MS", sans-serif; + --s2-body-size-xs: 12px; + --s2-component-m-regular-font-size: 14px; + --s2-corner-radius-300: 6px; + --s2-corner-radius-500: 8px; + --s2-corner-radius-800: 16px; + + display: block; + position: relative; + + --sl-field-height: 28px; + --sl-field-border: 2px solid var(--s2-gray-200); + --sl-fixed-family: "Roboto Mono", menlo, consolas, "Liberation Mono", monospace; +} + +:host > svg { + display: none; +} + +svg.icon { + width: 20px; + height: 20px; +} + +.sl-inputarea { + min-height: 64px; +} + +.sl-inputfield { + label { + font-size: var(--s2-body-size-xs); + display: block; + color: var(--s2-gray-500); + margin-bottom: 4px; + } + + input[type="date"] { + padding: 0 8px 0 12px; + } + + textarea { + position: absolute; + inset: 0; + } + + input[type="text"], + input[type="number"], + input[type="password"], + input[type="date"], + input[type="datetime-local"], + textarea { + width: 100%; + display: block; + padding: 0 12px; + background: var(--s2-gray-50); + line-height: var(--sl-field-height); + font-family: var(--s2-font-family); + font-size: var(--s2-component-m-regular-font-size); + border-radius: var(--s2-corner-radius-500); + outline-color: var(--s2-blue-900); + outline-offset: 0; + transition: outline-offset 0.2s; + border: var(--sl-field-border); + box-sizing: border-box; + + &.quiet { + background: transparent; + border: none; + padding: 0; + } + + &.monospace { + font-family: var(--fixed-family); + } + + &.has-label { + top: 22px; /* match label height */ + } + + &.has-error { + border: 2px solid var(--s2-red-900); + } + + &:disabled { + opacity: 1; + background: var(--s2-gray-75); + border: none; + color: var(--s2-gray-500); + } + + &[resize="none"] { + resize: none; + } + } + + input[type="range"] { + display: block; + position: relative; + appearance: none; + width: 100%; + outline: none; + background: transparent; + margin: 0; + } + + input[type="range"]::after { + content: ""; + height: 2px; + border-radius: 1px; + background: rgb(143 143 143); + position: absolute; + left: 0; + right: 0; + top: 50%; + } + + input[type="range"]:focus-visible::after { + background: var(--s2-blue-900); + margin-top: -1px; + height: 3px; + } + + input[type="range"]::-webkit-slider-thumb { + position: relative; + appearance: none; + border: 2px solid #464646; + margin-top: 2px; + background: #fff; + width: 16px; + height: 16px; + border-radius: 8px; + z-index: 1; + } + + input:focus-visible, + textarea:focus-visible { + outline-offset: 4px; + } + + .sl-inputfield-error { + font-size: var(--s2-body-size-xs); + color: var(--s2-red-900); + margin: 0; + } +} + +.sl-checkbox { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 8px; + + input[type="checkbox"] { + flex-shrink: 0; + margin: 0; + } + + input[type="checkbox"].has-error { + outline: 2px solid var(--s2-red-900); + outline-offset: 2px; + } + + label { + font-family: var(--s2-font-family); + font-size: var(--s2-body-size-xs); + color: rgb(80 80 80); + margin-bottom: 0; + order: 1; + cursor: pointer; + } + + .sl-inputfield-error { + flex-basis: 100%; + order: 2; + font-size: var(--s2-body-size-xs); + color: var(--s2-red-900); + margin: 0; + } +} + +.sl-button { + button { + display: flex; + justify-content: center; + min-width: 34px; + gap: 6px; + padding: 5px 14px; + line-height: 18px; + font-size: 14px; + color: #fff; + background: var(--s2-blue-900); + border: 2px solid var(--s2-blue-900); + font-family: var(--s2-font-family); + border-radius: var(--s2-corner-radius-800); + outline-color: var(--s2-blue-900); + outline-offset: 0; + transition: outline-offset 0.2s; + text-decoration: none; + font-weight: 700; + text-align: center; + cursor: pointer; + + &:focus-visible { + outline-offset: 4px; + } + + &:hover { + background: var(--s2-blue-1000); + border-color: var(--s2-blue-1000); + } + + &.negative { + background: var(--s2-red-900); + border-color: var(--s2-red-900); + outline-color: var(--s2-red-900); + + &:hover { + background: var(--s2-red-800); + border-color: var(--s2-red-800); + } + + &.outline { + background: transparent; + color: var(--s2-red-900); + + &:hover { + background: var(--s2-red-200); + border-color: var(--s2-red-800); + color: var(--s2-red-800); + } + + &:focus-visible { + color: #fff; + background: var(--s2-red-900); + border-color: var(--s2-red-900); + } + } + } + + &.icon-only { + padding: 5px 0; + } + + &.primary { + background: var(--s2-gray-800); + border: 2px solid var(--s2-gray-800); + color: #fff; + + &:hover { + background: var(--s2-gray-900); + border-color: var(--s2-gray-900); + } + + &.outline { + background: transparent; + color: var(--s2-gray-800); + + &:hover { + background: transparent; + border-color: var(--s2-gray-600); + color: var(--s2-gray-600); + } + } + } + + &:disabled { + opacity: 0.6; + pointer-events: none; + } + } +} + +.sl-dialog { + display: block; + visibility: hidden; + padding: 0; + border: none; + border-radius: 16px; + z-index: 100000; + transform: translateY(12px); + opacity: 0; + transition: + opacity 0.4s cubic-bezier(0, 0, 0.4, 1), + transform 0.4s cubic-bezier(0, 0, 0.4, 1); + box-shadow: + 0 0 6px 0 rgb(0 0 0 / 24%), + 0 3px 20px 0 rgb(0 0 0 / 16%), + 0 4px 20px 0 rgb(0 0 0 / 24%); + + &.overflow-hidden { + overflow: hidden; + } + + &[open] { + visibility: visible; + opacity: 1; + transform: translateY(0); + } + + &::backdrop { + background-color: rgb(0 0 0 / 60%); + } +} + +.sl-select { + position: relative; + width: 100%; +} + +.sl-select::after { + content: ""; + position: absolute; + top: 0; + right: 0; + width: 32px; + height: 32px; + background: no-repeat center / 18px url("https://da.live/nx/public/icons/Smock_ChevronDown_18_N.svg"); + pointer-events: none; + transition: transform 0.15s; +} + +.sl-select.open::after { + transform: rotate(180deg); +} + +.sl-select:has(.sl-select-trigger:disabled)::after { + display: none; +} + +.sl-select-trigger { + display: block; + width: 100%; + padding: 0 32px 0 12px; + background: var(--s2-gray-200); + font-family: var(--s2-font-family); + font-size: var(--s2-component-m-regular-font-size); + line-height: var(--sl-field-height); + color: var(--s2-gray-800); + border: 2px solid var(--s2-gray-200); + border-radius: var(--s2-corner-radius-500); + outline-color: var(--s2-blue-900); + outline-offset: 0; + transition: outline-offset 0.2s; + text-align: left; + cursor: pointer; + box-sizing: border-box; +} + +.sl-select-trigger:focus-visible { + outline-offset: 4px; +} + +.sl-select-trigger:disabled { + opacity: 1; + background: var(--s2-gray-75); + border: none; + color: var(--s2-gray-500); + cursor: default; +} + +.sl-select.has-error .sl-select-trigger { + border-color: var(--s2-red-900); +} + +.sl-select-label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sl-select-label.is-placeholder { + color: var(--s2-gray-500); +} + +.sl-select-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 10; + list-style: none; + margin: 0; + padding: 6px; + background: light-dark(#fff, var(--s2-gray-100)); + border: 1px solid var(--s2-gray-200); + border-radius: var(--s2-corner-radius-500); + box-shadow: 0 4px 12px rgb(0 0 0 / 12%); + max-height: 320px; + overflow-y: auto; +} + +.sl-select-group { + font-family: var(--s2-font-family); + font-size: var(--s2-body-size-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500); + padding: 8px 10px 4px; + user-select: none; +} + +.sl-select-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + font-family: var(--s2-font-family); + font-size: var(--s2-component-m-regular-font-size); + color: var(--s2-gray-800); + border-radius: var(--s2-corner-radius-300); + cursor: pointer; +} + +.sl-select-item.active { + background: var(--s2-gray-200); +} + +.sl-select-menu:focus-visible { + outline: none; +} + +.sl-select-check { + width: 14px; + height: 14px; + visibility: hidden; +} + +.sl-select-item.selected .sl-select-check { + visibility: visible; +} + +.sl-select-item.disabled { + color: var(--s2-gray-500); + cursor: default; + pointer-events: none; +} diff --git a/tools/plugins/msm/vendor/se/components.js b/tools/plugins/msm/vendor/se/components.js new file mode 100644 index 0000000..67ce281 --- /dev/null +++ b/tools/plugins/msm/vendor/se/components.js @@ -0,0 +1,570 @@ +/* eslint-disable */ +/* + * Vendored snapshot of da-nx's nx2/public/se/components.js + * (https://github.com/adobe/da-nx, ravuthu/se-select branch). + * + * Adapted for self-contained loading from this plugin: + * • loadStyle is inlined (upstream pulls it from ../../utils/utils.js, which + * we don't ship here). + * • The se-select checkmark reference to an external SVG sprite is + * replaced with an inline , so it works regardless of origin. + * + * Everything else (component logic, classes, exported tag names) mirrors + * upstream so the public API stays identical to what OOTB MSM Prepare uses. + * + * To refresh: re-copy from da-nx and re-apply the two patches noted above. + */ + +import { LitElement, html, nothing, spread } from 'https://da.live/deps/lit/dist/index.js'; + +const loadStyle = async (url) => { + const cssUrl = url.replace(/\.js(\?.*)?$/, '.css'); + const resp = await fetch(cssUrl); + const text = await resp.text(); + const sheet = new CSSStyleSheet(); + sheet.replaceSync(text); + return sheet; +}; + +const style = await loadStyle(import.meta.url); + +class SlInput extends LitElement { + static formAssociated = true; + + static properties = { + value: { type: String }, + class: { type: String }, + label: { type: String }, + error: { type: String }, + name: { type: String }, + }; + + constructor() { + super(); + this._internals = this.attachInternals(); + } + + async connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + this._internals.setFormValue(this.value); + } + + focus() { + this.shadowRoot.querySelector('input').focus(); + } + + handleEvent(event) { + this.value = event.target.value; + this._internals.setFormValue(this.value); + const wcEvent = new event.constructor(event.type, event); + this.dispatchEvent(wcEvent); + } + + handleKeyDown(event) { + if (event.key !== 'Enter') return; + if (!this.form) return; + + const submitEvent = new SubmitEvent('submit', { bubbles: true, cancelable: true }); + this.form.dispatchEvent(submitEvent); + + if (submitEvent.defaultPrevented) return; + this.form.submit(); + } + + get _attrs() { + return this.getAttributeNames().reduce((acc, name) => { + if ((name === 'class' || name === 'label' || name === 'value' || name === 'error')) return acc; + acc[name] = this.getAttribute(name); + return acc; + }, {}); + } + + get form() { return this._internals.form; } + + render() { + return html` +
    + ${this.label ? html`` : nothing} + + ${this.error ? html`

    ${this.error}

    ` : nothing} +
    + `; + } +} + +class SlTextarea extends LitElement { + static formAssociated = true; + + static properties = { + value: { type: String }, + class: { type: String }, + label: { type: String }, + error: { type: String }, + name: { type: String }, + }; + + constructor() { + super(); + this._internals = this.attachInternals(); + } + + async connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + this._internals.setFormValue(this.value); + } + + handleEvent(event) { + this.value = event.target.value; + this._internals.setFormValue(this.value); + const wcEvent = new event.constructor(event.type, event); + this.dispatchEvent(wcEvent); + } + + get form() { return this._internals.form; } + + get _attrs() { + return this.getAttributeNames().reduce((acc, name) => { + if ((name === 'class' || name === 'label' || name === 'value' || name === 'error')) return acc; + acc[name] = this.getAttribute(name); + return acc; + }, {}); + } + + render() { + return html` +
    + ${this.label ? html`` : nothing} + + ${this.error ? html`

    ${this.error}

    ` : nothing} +
    + `; + } +} + +class SlCheckbox extends LitElement { + static formAssociated = true; + + static properties = { + name: { type: String }, + checked: { type: Boolean }, + error: { type: String }, + }; + + constructor() { + super(); + this._internals = this.attachInternals(); + } + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + this._updateFormValue(); + } + + get type() { + return 'checkbox'; + } + + get value() { + return this.checked ? 'true' : ''; + } + + _updateFormValue() { + if (this.checked) { + this._internals.setFormValue('true'); + } else { + this._internals.setFormValue(''); + } + } + + handleChange(event) { + this.checked = event.target.checked; + this._updateFormValue(); + const wcEvent = new event.constructor(event.type, { bubbles: true, composed: true }); + this.dispatchEvent(wcEvent); + } + + render() { + return html` +
    + + + ${this.error ? html`

    ${this.error}

    ` : nothing} +
    + `; + } +} + +class SlSelect extends LitElement { + static formAssociated = true; + + static readOption(opt) { + return { + value: opt.value, + label: opt.textContent.trim(), + disabled: opt.hasAttribute('disabled'), + }; + } + + static properties = { + name: { type: String }, + label: { type: String }, + value: { type: String }, + disabled: { type: Boolean }, + placeholder: { type: String }, + error: { type: String }, + _open: { state: true }, + _activeIndex: { state: true }, + _groups: { state: true }, + }; + + constructor() { + super(); + this._open = false; + this._activeIndex = -1; + } + + _restoreFocusOnClose = false; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + this._internals = this.attachInternals(); + this._internals.setFormValue(this.value); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._removeOutsideListeners(); + } + + update(props) { + if (props.has('value')) this._internals.setFormValue(this.value); + if (props.has('_open')) { + if (this._open) this._addOutsideListeners(); + else this._removeOutsideListeners(); + } + super.update(props); + } + + updated(props) { + if (props.has('_open')) { + if (this._open) { + this.shadowRoot.querySelector('.sl-select-menu')?.focus(); + this._scrollActiveIntoView(); + } else if (this._restoreFocusOnClose) { + this._restoreFocusOnClose = false; + this.shadowRoot.querySelector('.sl-select-trigger')?.focus(); + } + } + if (props.has('_activeIndex') && this._open) this._scrollActiveIntoView(); + } + + _scrollActiveIntoView() { + const items = this.shadowRoot.querySelectorAll('.sl-select-item'); + items[this._activeIndex]?.scrollIntoView({ block: 'nearest' }); + } + + handleSlotchange(e) { + this._buildGroups(e.target.assignedNodes({ flatten: true })); + } + + _buildGroups(nodes) { + const groups = nodes.reduce((acc, node) => { + if (node.nodeName === 'OPTGROUP') { + const group = { + heading: node.label || '', + items: [...node.querySelectorAll('option')].map(SlSelect.readOption), + }; + return [...acc, group]; + } + if (node.nodeName === 'OPTION') { + const last = acc[acc.length - 1]; + const updated = { ...last, items: [...last.items, SlSelect.readOption(node)] }; + return [...acc.slice(0, -1), updated]; + } + return acc; + }, [{ heading: null, items: [] }]); + + this._groups = groups.filter((g) => g.items.length > 0); + + if (!this.value) { + const first = this._flatOptions().find((o) => !o.disabled); + if (first) { + this.value = first.value; + this._internals.setFormValue(this.value); + } + } + } + + _flatOptions() { + return this._groups?.flatMap((g) => g.items) ?? []; + } + + _selectedLabel() { + const match = this._flatOptions().find((o) => o.value === this.value); + return match?.label ?? this.placeholder ?? ''; + } + + _toggle() { + if (this.disabled) return; + this._open = !this._open; + if (this._open) this._activeIndex = -1; + } + + _close({ returnFocus = false } = {}) { + if (returnFocus) this._restoreFocusOnClose = true; + this._open = false; + } + + _addOutsideListeners() { + document.addEventListener('pointerdown', this._onDocPointerDown, true); + } + + _removeOutsideListeners() { + document.removeEventListener('pointerdown', this._onDocPointerDown, true); + } + + _onDocPointerDown = (e) => { + if (!e.composedPath().includes(this)) this._close(); + }; + + _selectValue(value) { + if (this.value !== value) { + this.value = value; + this._internals.setFormValue(value); + this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + } + this._close({ returnFocus: true }); + } + + _onTriggerKeydown(e) { + if (this.disabled) return; + if (this._open) { + if (e.key === 'Escape') { + e.preventDefault(); + this._close({ returnFocus: true }); + } + return; + } + if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) { + e.preventDefault(); + this._toggle(); + } + } + + _onMenuKeydown(e) { + const flat = this._flatOptions(); + const moveTo = (start, dir) => { + let i = start; + for (let n = 0; n < flat.length; n += 1) { + i = (i + dir + flat.length) % flat.length; + if (!flat[i].disabled) return i; + } + return start; + }; + if (e.key === 'ArrowDown') { + e.preventDefault(); + this._activeIndex = moveTo(this._activeIndex, 1); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + this._activeIndex = moveTo(this._activeIndex, -1); + } else if (e.key === 'Home') { + e.preventDefault(); + this._activeIndex = moveTo(-1, 1); + } else if (e.key === 'End') { + e.preventDefault(); + this._activeIndex = moveTo(0, -1); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + const opt = flat[this._activeIndex]; + if (opt && !opt.disabled) this._selectValue(opt.value); + } else if (e.key === 'Escape' || e.key === 'Tab') { + e.preventDefault(); + this._close({ returnFocus: true }); + } + } + + render() { + const selectedLabel = this._selectedLabel(); + let runningIndex = -1; + return html` + +
    + ${this.label ? html`` : nothing} +
    + + ${this._open ? html` +
      + ${this._groups?.map((group) => html` + ${group.heading ? html` + + ` : nothing} + ${group.items.map((opt) => { + runningIndex += 1; + const idx = runningIndex; + const isActive = idx === this._activeIndex; + const isSelected = opt.value === this.value; + return html` +
    • { this._activeIndex = idx; }} + @click=${() => !opt.disabled && this._selectValue(opt.value)}> + + + + ${opt.label} +
    • `; + })} + `)} +
    + ` : nothing} +
    + ${this.error ? html`

    ${this.error}

    ` : nothing} +
    + `; + } +} + +class SlButton extends LitElement { + static formAssociated = true; + + static properties = { + class: { type: String }, + disabled: { type: Boolean }, + type: { type: String }, + }; + + constructor() { + super(); + this._internals = this.attachInternals(); + this.type = 'button'; + } + + async connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + } + + get _attrs() { + return this.getAttributeNames().reduce((acc, name) => { + if ((name === 'class' || name === 'label' || name === 'disabled' || name === 'type')) return acc; + acc[name] = this.getAttribute(name); + return acc; + }, {}); + } + + handleClick() { + if (this.disabled) return; + const { form } = this._internals; + if (!form) return; + if (this.type === 'submit') form.requestSubmit(); + else if (this.type === 'reset') form.reset(); + } + + render() { + return html` + + + `; + } +} + +class SlDialog extends LitElement { + static properties = { + open: { type: Boolean }, + modal: { type: Boolean }, + overflow: { type: String }, + _showLazyModal: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + } + + updated() { + if (this._showLazyModal && this._dialog) { + this._showLazyModal = undefined; + this.showModal(); + } + } + + showModal() { + if (!this._dialog) { + this._showLazyModal = true; + return; + } + this._dialog.showModal(); + } + + show() { + this._dialog.show(); + } + + close() { + this._dialog.close(); + } + + onClose(e) { + this.dispatchEvent(new Event('close', e)); + } + + get _dialog() { + return this.shadowRoot.querySelector('dialog'); + } + + render() { + return html` + + + `; + } +} + +if (!customElements.get('se-input')) customElements.define('se-input', SlInput); +if (!customElements.get('se-textarea')) customElements.define('se-textarea', SlTextarea); +if (!customElements.get('se-checkbox')) customElements.define('se-checkbox', SlCheckbox); +if (!customElements.get('se-select')) customElements.define('se-select', SlSelect); +if (!customElements.get('se-button')) customElements.define('se-button', SlButton); +if (!customElements.get('se-dialog')) customElements.define('se-dialog', SlDialog); From 624f223e3f8dac8592501727ec0db237a265d1c8 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Thu, 21 May 2026 17:58:14 -0400 Subject: [PATCH 12/26] msm plugin ux update --- tools/plugins/msm/msm.css | 520 +++++++++++++++++------------ tools/plugins/msm/msm.js | 686 ++++++++++++++++++++++++-------------- 2 files changed, 744 insertions(+), 462 deletions(-) diff --git a/tools/plugins/msm/msm.css b/tools/plugins/msm/msm.css index c7cacbc..bedb22f 100644 --- a/tools/plugins/msm/msm.css +++ b/tools/plugins/msm/msm.css @@ -1,10 +1,9 @@ /* * MSM plugin styles * - * Mirrors the styles from da-live's blocks/edit/da-prepare/actions/msm/msm.css - * with one adaptation for the plugin context: the component fills the host - * iframe (which da-prepare locks to 400x400) rather than sizing itself to - * 420px the way the in-process OOTB version does. + * The plugin fills the 400×400 iframe that DA-Prepare locks it to. + * The default view is "two big primary buttons + chips of in-scope sites"; + * power-user controls live under a "More options" expander. */ :host { @@ -28,6 +27,13 @@ color: var(--s2-gray-600, #717171); } +.empty-state { + font-size: 12px; + color: var(--s2-gray-600, #717171); + margin: 0; + font-style: italic; +} + /* --- Inheritance breadcrumb (static) --- */ .crumb-row { @@ -57,258 +63,365 @@ color: var(--s2-gray-400, #b1b1b1); } -/* --- Direction switch (Spectrum 2 Switch, size M) --- */ +/* --- Primary buttons (the simplified default view) --- + * Spectrum-style pill buttons, stacked vertically so the full action label + * fits without truncation. Both buttons share the same neutral palette (no + * brand accent / no destructive red) — hierarchy comes from treatment + * (first is `fill`, second is `outline`) and from icon. */ + +.primary-buttons { + display: flex; + flex-direction: column; + gap: 8px; + align-items: stretch; +} -.direction-switch { +.primary-btn { display: inline-flex; align-items: center; - gap: 10px; + justify-content: center; + gap: 6px; + min-width: 0; + height: 32px; + padding: 0 14px; + border-radius: 16px; + font-family: inherit; font-size: 14px; - color: var(--s2-gray-900, #292929); + font-weight: 500; + line-height: 1; cursor: pointer; - user-select: none; - width: max-content; + white-space: nowrap; + background: transparent; + color: var(--s2-gray-800, #292929); + border: 2px solid var(--s2-gray-800, #292929); + transition: + background-color 0.13s ease-in-out, + color 0.13s ease-in-out, + border-color 0.13s ease-in-out, + transform 0.05s ease-in-out; } -.direction-switch input[type="checkbox"] { - appearance: none; - -webkit-appearance: none; - position: relative; - margin: 0; - width: 26px; - height: 14px; - border-radius: 7px; - background: var(--s2-gray-300, #d4d4d4); - cursor: pointer; +.primary-btn.fill { + background: var(--s2-gray-800, #292929); + color: #fff; +} + +.primary-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.primary-btn:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; +} + +.primary-btn:active:not(:disabled) { + transform: translateY(0.5px); +} + +.primary-btn.outline:hover:not(:disabled) { + background: var(--s2-gray-100, #f5f5f5); +} + +.primary-btn.fill:hover:not(:disabled) { + background: #000; + border-color: #000; +} + +.primary-btn-icon { + width: 18px; + height: 18px; flex-shrink: 0; - transition: background-color 0.15s ease-in-out; + display: block; } -.direction-switch input[type="checkbox"]::after { - content: ''; - position: absolute; - top: 2px; - left: 2px; - width: 10px; - height: 10px; - border-radius: 50%; - background: #fff; - box-shadow: 0 1px 2px rgb(0 0 0 / 0.2); - transition: transform 0.15s ease-in-out; +.primary-btn-label { + overflow: hidden; + text-overflow: ellipsis; } -.direction-switch input[type="checkbox"]:checked { - background: var(--s2-blue-700, #393dba); +/* --- Site chips (in-scope satellites) --- */ + +.chips-section { + display: flex; + flex-direction: column; + gap: 6px; } -.direction-switch input[type="checkbox"]:checked::after { - transform: translateX(12px); +.chips-label { + font-size: 12px; + color: var(--s2-gray-700, #4b4b4b); + margin: 0; +} + +.chips-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.site-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + font-size: 12px; + font-family: inherit; + line-height: 1.2; + border: 1px solid var(--s2-gray-300, #d4d4d4); + background: #fff; + color: var(--s2-gray-800, #292929); + border-radius: 14px; + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s, color 0.15s; } -.direction-switch input[type="checkbox"]:hover:not(:disabled) { - background: var(--s2-gray-400, #b1b1b1); +.site-chip.selected { + background: var(--s2-gray-200, #e1e1e1); + border-color: var(--s2-gray-600, #717171); + color: var(--s2-gray-900, #292929); + font-weight: 600; } -.direction-switch input[type="checkbox"]:checked:hover:not(:disabled) { - background: var(--s2-blue-800, #2c3093); +.site-chip:disabled { + opacity: 0.5; + cursor: default; } -.direction-switch input[type="checkbox"]:focus-visible { +.site-chip:focus-visible { outline: 2px solid var(--s2-blue-700, #393dba); outline-offset: 2px; } -.direction-switch input[type="checkbox"]:disabled { - opacity: 0.4; - cursor: default; +.site-chip:hover:not(:disabled) { + border-color: var(--s2-gray-500, #929292); } -.descendant-badge { - display: inline-block; - margin-left: 6px; - padding: 0 6px; +.site-chip.selected:hover:not(:disabled) { + border-color: var(--s2-gray-800, #292929); +} + +.chip-check { + color: var(--s2-gray-800, #292929); + font-weight: 700; + font-size: 11px; + line-height: 1; +} + +.chip-descendants { + margin-left: 2px; + padding: 0 5px; font-size: 10px; font-weight: 600; color: var(--s2-gray-700, #4b4b4b); background: var(--s2-gray-200, #e1e1e1); - border-radius: 8px; - vertical-align: middle; + border-radius: 6px; +} + +.site-chip.selected .chip-descendants { + background: #fff; } -/* --- Action row --- */ +/* --- Out-of-scope chip variants --- + * Shown for sites that exist but aren't affected by the current action. + * Customized out-of-scope chips link to the editor (an ); inherited + * out-of-scope chips are decorative (a ). */ -.action-row { - display: grid; - /* Always two equal columns — the action picker stays at 50% width whether - or not the sync-mode picker is visible, so the layout stays stable. */ - grid-template-columns: 1fr 1fr; - gap: 12px; +.site-chip.out-of-scope { + opacity: 0.45; + background: #fff; + border-color: var(--s2-gray-300, #d4d4d4); + color: var(--s2-gray-700, #4b4b4b); + font-weight: 400; + cursor: default; } -.action-row se-select { - width: 100%; - min-width: 0; +.site-chip.out-of-scope.link { + opacity: 0.7; + cursor: pointer; + text-decoration: none; } -/* --- Upward summary (Source / Target / Override status) --- */ +.site-chip.out-of-scope.link:hover { + opacity: 1; + border-color: var(--s2-gray-500, #929292); + background: var(--s2-gray-50, #f8f8f8); +} -.upward-summary { - display: flex; - flex-direction: column; - gap: 6px; - font-size: 13px; +.chip-link-icon { + width: 12px; + height: 12px; + margin-left: 2px; + flex-shrink: 0; + color: var(--s2-gray-600, #717171); } -.upward-summary .row { +/* --- Status line (upward / satellite view) --- */ + +.status-line { display: flex; align-items: center; - justify-content: space-between; - gap: 12px; - min-height: 20px; + gap: 8px; + font-size: 13px; + padding: 8px 10px; + background: var(--s2-gray-50, #f8f8f8); + border-radius: 6px; } -.upward-summary .label { - color: var(--s2-gray-600, #717171); +.status-label { + color: var(--s2-gray-700, #4b4b4b); } -.upward-summary .value { +.status-value { font-weight: 600; color: var(--s2-gray-900, #292929); - display: inline-flex; - align-items: center; - gap: 6px; } -.upward-summary .value.muted { +.status-value.muted { font-weight: 500; color: var(--s2-gray-600, #717171); } -/* --- Children list (two-column: Inherited | Custom) --- */ +/* --- Bottom section (direction-flip + advanced + app link) --- */ -.satellite-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; -} - -.satellite-column { +.bottom-section { display: flex; flex-direction: column; - min-width: 0; + gap: 10px; + margin-top: 4px; + padding-top: 12px; + border-top: 1px solid var(--s2-gray-200, #e1e1e1); } -.column-heading { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--s2-gray-600, #717171); - padding-bottom: 4px; - border-bottom: 1px solid var(--s2-gray-200, #e1e1e1); - margin: 0 0 4px; +.direction-flip { + appearance: none; + background: none; + border: none; + padding: 0; + font-family: inherit; + font-size: 12px; + color: var(--s2-gray-700, #4b4b4b); + cursor: pointer; + text-align: left; + width: max-content; + text-decoration: underline; } -.satellite-list { - list-style: none; - padding: 0; - margin: 0; +.direction-flip:disabled { + opacity: 0.5; + cursor: default; + text-decoration: none; +} + +.direction-flip:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; + border-radius: 2px; +} + +.direction-flip:hover:not(:disabled) { + color: var(--s2-gray-900, #292929); +} + +/* --- Advanced expander --- */ + +.advanced-section { display: flex; flex-direction: column; - max-height: 240px; - overflow-y: auto; - - &::-webkit-scrollbar { width: 5px; } - &::-webkit-scrollbar-track { background: transparent; } - &::-webkit-scrollbar-thumb { - background: var(--s2-gray-300, #d4d4d4); - border-radius: 3px; - &:hover { background: var(--s2-gray-500, #929292); } - } + gap: 10px; } -.sat-row { - display: flex; +.advanced-toggle { + appearance: none; + background: none; + border: none; + padding: 0; + font-family: inherit; + font-size: 13px; + color: var(--s2-gray-700, #4b4b4b); + cursor: pointer; + text-align: left; + display: inline-flex; align-items: center; - gap: 8px; - padding: 6px 4px; - border-radius: 4px; - transition: background-color 0.1s; + gap: 6px; + width: max-content; +} - &:hover:not(.out-of-scope) { background: var(--s2-gray-50, #f8f8f8); } +.advanced-toggle:hover { + color: var(--s2-gray-900, #292929); +} - &.out-of-scope { - opacity: 0.38; - } +.advanced-toggle:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; + border-radius: 2px; +} - label { - display: flex; - align-items: center; - gap: 8px; - flex: 1; - min-width: 0; - cursor: pointer; - font-size: 14px; - color: var(--s2-gray-900, #292929); - } +.advanced-chevron { + display: inline-block; + font-size: 10px; + color: var(--s2-gray-500, #929292); + transition: transform 0.15s ease-in-out; +} - label span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } +.advanced-chevron.open { + transform: rotate(90deg); } -/* --- S2 checkbox (matches spectrum-two tokens) --- */ +.advanced-content { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + border: 1px solid var(--s2-gray-200, #e1e1e1); + border-radius: 8px; + background: var(--s2-gray-50, #f8f8f8); +} -.sat-row input[type="checkbox"], -.footer-cascade input[type="checkbox"] { - appearance: none; - -webkit-appearance: none; - width: 14px; - height: 14px; - margin: 0; - border: 2px solid var(--s2-gray-600, #717171); +/* --- App deep-link --- */ + +.app-link { + display: inline-flex; + align-items: center; + font-size: 13px; + color: var(--s2-blue-900, #3b63fb); + text-decoration: underline; + width: max-content; +} + +.app-link:hover { + color: var(--s2-blue-1000, #274dea); +} + +.app-link:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; border-radius: 2px; - cursor: pointer; - flex-shrink: 0; - position: relative; - transition: border 0.13s ease-in-out; - - &:checked { - border-color: var(--s2-gray-800, #292929); - border-width: 7px; - background: var(--s2-gray-50, #f8f8f8); - - &::after { - content: ''; - position: absolute; - inset: 0; - top: -2px; - left: -3px; - width: 4px; - height: 8px; - margin: auto; - border: solid var(--s2-gray-50, #f8f8f8); - border-width: 0 2px 2px 0; - transform: rotate(45deg); - } - } +} - &:focus-visible { - outline: 2px solid var(--s2-blue-700, #393dba); - outline-offset: 2px; - } +/* --- Action row (inside Advanced) --- */ - &:disabled { opacity: 0.4; cursor: default; } +.action-row { + display: grid; - &:hover:not(:disabled) { - border-color: var(--s2-gray-700, #4b4b4b); - } + /* Always two equal columns — the action picker stays at 50% width whether + or not the sync-mode picker is visible, so the layout stays stable. */ + grid-template-columns: 1fr 1fr; + gap: 12px; +} - &:checked:hover:not(:disabled) { - border-color: var(--s2-gray-800, #292929); - } +.action-row se-select { + width: 100%; + min-width: 0; +} + +/* --- Advanced hint --- */ + +.advanced-hint { + font-size: 12px; + color: var(--s2-gray-600, #717171); + margin: 0; } /* --- Status icons --- */ @@ -319,8 +432,15 @@ flex-shrink: 0; } +.site-chip .result-icon { + width: 12px; + height: 12px; + margin-left: 2px; +} + .result-icon.success { color: #0d6e31; } .result-icon.error { color: var(--s2-red-900, #d31510); } + .result-icon.pending { color: var(--s2-gray-600, #717171); animation: spin 1s linear infinite; @@ -331,32 +451,7 @@ to { transform: rotate(360deg); } } -/* --- Icon button (open-in-editor) --- */ - -.icon-btn { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - flex-shrink: 0; - border: none; - border-radius: 4px; - background: none; - color: var(--s2-gray-500, #929292); - cursor: pointer; - padding: 0; - text-decoration: none; - transition: background-color 0.15s, color 0.15s; - - svg { width: 14px; height: 14px; fill: currentColor; } - &:hover { - background: var(--s2-gray-200, #e1e1e1); - color: var(--s2-gray-900, #292929); - } -} - -/* --- Footer (single, shared) --- */ +/* --- Advanced footer (Apply button) --- */ .form-actions { display: flex; @@ -365,17 +460,6 @@ gap: 8px; } -.footer-cascade { - margin-right: auto; - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 13px; - color: var(--s2-gray-800, #292929); - cursor: pointer; - user-select: none; -} - /* --- Confirm dialog (yellow caution box) --- */ .confirm-box { @@ -386,7 +470,10 @@ font-size: 14px; color: var(--s2-gray-900, #292929); - p { margin: 0 0 10px; line-height: 1.4; } + p { + margin: 0 0 10px; + line-height: 1.4; + } .confirm-actions { display: flex; @@ -422,6 +509,7 @@ &.danger { color: var(--s2-red-900, #d31510); border-color: #f0c8c2; + &:hover { background: #fef0ee; border-color: var(--s2-red-900, #d31510); diff --git a/tools/plugins/msm/msm.js b/tools/plugins/msm/msm.js index e21e697..0f15da7 100644 --- a/tools/plugins/msm/msm.js +++ b/tools/plugins/msm/msm.js @@ -4,7 +4,6 @@ import { LitElement, html, nothing } from 'da-lit'; import DA_SDK from 'https://da.live/nx/utils/sdk.js'; import { getSiteConfig, - getSubtreeSatellites, isPageLocal, checkOverrides, setSdkFetch as setConfigSdkFetch, @@ -35,8 +34,16 @@ const ACTION_SCOPE = { reset: 'custom', }; +// Hosted location of the full-page MSM app. The "Manage variants in MSM" +// hand-off deep-links into it with the current org/site/path pre-loaded +// (see helpers/parseDeepLink in tools/apps/msm/msm.js). +const MSM_APP_URL = 'https://da.live/app/aemsites/da-blog-tools/tools/apps/msm/msm'; + // Icons are referenced as absolute URLs to da.live so they resolve correctly // even though the plugin is served from a different origin (e.g. da-blog-tools). +// All icons in the plugin use the Spectrum 2 workflow icons hosted there via +// `` — see +// `renderStatusIcon` for the canonical pattern. const ICON_BASE = 'https://da.live/blocks/edit/img'; // Component / style pipeline. We pull two element families to mirror OOTB: @@ -93,7 +100,7 @@ class DaMsm extends LitElement { _asSatellite: { state: true }, _hasOverride: { state: true }, _satStatus: { state: true }, - _includeDescendants: { state: true }, + _showAdvanced: { state: true }, }; connectedCallback() { @@ -104,10 +111,23 @@ class DaMsm extends LitElement { this._action = 'preview'; this._syncMode = SYNC_MODE.merge; this._busy = false; - this._includeDescendants = false; + this._showAdvanced = false; this.loadConfig(); } + updated(changedProperties) { + // When the user expands "More options", scroll the newly-rendered panel + // into view so the picker and Apply button are visible without manually + // scrolling the 400px-tall iframe. requestAnimationFrame defers the call + // until after the browser has laid out the freshly-mounted content. + if (changedProperties.has('_showAdvanced') && this._showAdvanced) { + requestAnimationFrame(() => { + const panel = this.shadowRoot?.querySelector('.advanced-content'); + panel?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }); + } + } + async loadConfig() { const { org, site, path } = this.details; this._loading = 'Loading configuration\u2026'; @@ -137,9 +157,15 @@ class DaMsm extends LitElement { this._action = 'sync-from-base'; } + // Auto-select all in-scope satellites for the default action so authors + // don't have to tick boxes for the common "roll out to everyone" flow. + this._seedSelectionForAction(this._action); + this._loading = undefined; } + // ── Derived state ─────────────────────────────────────────────────── + get _inherited() { return this._satellites?.filter((s) => !s.hasOverride) || []; } @@ -162,52 +188,80 @@ class DaMsm extends LitElement { return SYNC_ACTIONS.has(this._action); } - get _isRecursiveActive() { - return RECURSIVE_ACTIONS.has(this._action); - } - get _hasDualRole() { return !!(this._asBase && this._asSatellite); } - get _totalDescendants() { - // Only count descendants of satellites that are in scope for the active - // action. A custom-overridden satellite with nested children is not - // reachable from an inherited-scope action (e.g. Roll out to preview), so - // its descendants must not trigger the cascade UI. + get _isSatelliteOnly() { + return !!(this._asSatellite && !this._asBase); + } + + // The upward primary view (Pull / Revert buttons) is shown when the page + // is satellite-only OR when the author has flipped a dual-role page to + // "Update from parent instead". + get _showUpwardView() { + return this._isSatelliteOnly || (this._hasDualRole && this._isUpwardMode); + } + + get _canApplyDownward() { + return !this._busy && this._directTargets.length > 0; + } + + // True when `sat` belongs to the pool the current action affects. Drives + // chip interactivity: in-scope chips are toggleable, out-of-scope chips + // either link to the editor (customized) or render as plain decoration + // (inherited). + _isInScope(sat) { const scope = ACTION_SCOPE[this._action]; - if (!scope) return 0; - const pool = scope === 'custom' ? this._custom : this._inherited; - return pool.reduce((acc, s) => acc + (s.descendantCount || 0), 0); + if (!scope) return false; + return scope === 'custom' ? !!sat.hasOverride : !sat.hasOverride; } - async _expandedTargetSites() { - if (!this._includeDescendants || !RECURSIVE_ACTIONS.has(this._action)) { - return this._directTargets.map((s) => s.site); + // ── Selection / advanced toggle / app deep-link helpers ───────────── + + // Seeds `_selected` with every satellite whose override state matches the + // given action's scope. Upward actions (which target the satellite itself, + // not its children) clear the selection. + _seedSelectionForAction(action) { + if (!this._satellites) { + this._selected = new Set(); + return; + } + const scope = ACTION_SCOPE[action]; + if (!scope) { + this._selected = new Set(); + return; } - const { org } = this.details; - const seen = new Set(); - const ordered = []; - await Promise.all(this._directTargets.map(async (target) => { - if (!seen.has(target.site)) { - seen.add(target.site); - ordered.push(target.site); + const pool = scope === 'custom' ? this._custom : this._inherited; + this._selected = new Set(pool.map((s) => s.site)); + } + + _toggleAdvanced() { + const opening = !this._showAdvanced; + if (opening) { + // Picker doesn't include preview/publish (covered by primary buttons), + // so when opening from a primary action, default to the first advanced + // action so the picker has a valid selection. + if (RECURSIVE_ACTIONS.has(this._action)) { + this.onActionChange('break'); } - const subtree = await getSubtreeSatellites(org, target.site); - subtree.forEach((s) => { - if (!seen.has(s.site)) { - seen.add(s.site); - ordered.push(s.site); - } - }); - })); - return ordered; + } else if (!RECURSIVE_ACTIONS.has(this._action)) { + // Closing: snap back to the primary action so the chips above reflect + // what the visible primary buttons will do (instead of leaving them + // wired up to whatever advanced action the user last picked). + this.onActionChange('preview'); + } + this._showAdvanced = opening; } - get _canApplyDownward() { - return !this._busy && this._directTargets.length > 0; + _getAppDeepLink() { + const { org, site, path } = this.details; + const params = new URLSearchParams({ org, site, path }); + return `${MSM_APP_URL}?${params.toString()}`; } + // ── Handlers ──────────────────────────────────────────────────────── + handleToggle(site) { const next = new Set(this._selected); if (next.has(site)) next.delete(site); @@ -229,12 +283,44 @@ class DaMsm extends LitElement { this._action = value; this.clearStatuses(); this._satStatus = undefined; + // Re-seed selection so chips/list reflect what the new action can target. + this._seedSelectionForAction(value); } - // Direction switch flips the action picker between upward (parent) and - // downward (children) modes. The picker's options are filtered to match. + // Flips between downward (children) and upward (parent) directions for + // dual-role pages. The next action depends on which surface will be + // visible after the flip — the Advanced picker (only shown in downward + // mode) doesn't expose 'preview'/'publish', so we must snap to one of + // its options ('break') when Advanced will remain open, otherwise to the + // primary-button default. onDirectionToggle(toUpward) { - this.onActionChange(toUpward ? 'sync-from-base' : 'preview'); + let nextAction; + if (toUpward) { + nextAction = 'sync-from-base'; + } else if (this._showAdvanced) { + nextAction = 'break'; + } else { + nextAction = 'preview'; + } + this.onActionChange(nextAction); + } + + // Quick-action triggered by the big primary buttons. Sets the action, + // ensures selection is seeded, and runs the same `apply()` flow as the + // advanced UI would. + async runQuickAction(action) { + if (this._busy) return; + const oldScope = ACTION_SCOPE[this._action]; + const newScope = ACTION_SCOPE[action]; + this._action = action; + this.clearStatuses(); + this._satStatus = undefined; + // Re-seed when the scope changed, or when the user previously cleared + // the chips — both cases mean "do this action with the natural defaults". + if (oldScope !== newScope || (newScope && this._selected.size === 0)) { + this._seedSelectionForAction(action); + } + await this.apply(); } async apply() { @@ -247,17 +333,7 @@ class DaMsm extends LitElement { if (this._action === 'reset') { const names = this._directTargets.map((s) => s.label).join(', '); - this._confirmAction = { message: `Resume inheritance for ${names}? This deletes local overrides.` }; - return; - } - - if (this._includeDescendants && RECURSIVE_ACTIONS.has(this._action)) { - const directLabels = this._directTargets.map((s) => s.label).join(', '); - const surface = this._action === 'preview' ? 'preview' : 'live'; - this._confirmAction = { - message: `Roll out ${directLabels} and all their descendants to ${surface}? Inherited content will be served at every site in the subtree.`, - confirmedAction: this._action, - }; + this._confirmAction = { message: `Discard local copy on ${names}? Removes the satellite override.` }; return; } @@ -285,8 +361,7 @@ class DaMsm extends LitElement { const { org, site, path } = this.details; const directTargets = this._directTargets; - const expandedSites = await this._expandedTargetSites(); - const recursive = RECURSIVE_ACTIONS.has(action) && this._includeDescendants; + const targetSites = directTargets.map((s) => s.site); directTargets.forEach((s) => this.updateSatStatus(s.site, STATUS.pending)); @@ -295,21 +370,13 @@ class DaMsm extends LitElement { case 'publish': { const fn = action === 'publish' ? publishSatellite : previewSatellite; const results = await Promise.allSettled( - expandedSites.map((satSite) => fn(org, satSite, path)), + targetSites.map((satSite) => fn(org, satSite, path)), ); - if (recursive) { - const allOk = results.every((r) => r.status === 'fulfilled' && !r.value?.error); - directTargets.forEach((s) => this.updateSatStatus( - s.site, - allOk ? STATUS.success : STATUS.error, - )); - } else { - results.forEach((r, idx) => { - const satSite = expandedSites[idx]; - const ok = r.status === 'fulfilled' && !r.value?.error; - this.updateSatStatus(satSite, ok ? STATUS.success : STATUS.error); - }); - } + results.forEach((r, idx) => { + const satSite = targetSites[idx]; + const ok = r.status === 'fulfilled' && !r.value?.error; + this.updateSatStatus(satSite, ok ? STATUS.success : STATUS.error); + }); break; } @@ -385,7 +452,7 @@ class DaMsm extends LitElement { if (this._action === 'resume-inheritance') { this._confirmAction = { - message: 'Resume inheritance? This deletes the local override.', + message: 'Revert to base? This deletes the local copy on this satellite.', confirmedAction: 'resume-inheritance', }; return; @@ -403,7 +470,13 @@ class DaMsm extends LitElement { try { let result; if (action === 'sync-from-base') { - result = this._syncMode === SYNC_MODE.merge + // Smart sync mode: merge when there's a local copy to preserve any + // edits, override when there's nothing local to merge against. Read + // `_hasOverride` at execution time so repeated pulls do the right + // thing after the first one materialises a local copy. Authors who + // need explicit control over merge vs override should use the app. + const useMerge = this._hasOverride; + result = useMerge ? await mergeFromBase(org, baseSite, site, path) : await createOverride(org, baseSite, site, path); } else if (action === 'resume-inheritance') { @@ -453,35 +526,6 @@ class DaMsm extends LitElement { `; } - renderSatellite(sat) { - const scope = ACTION_SCOPE[this._action]; - const isBaseAction = scope === 'inherited' || scope === 'custom'; - const outOfScope = isBaseAction && ((scope === 'inherited') === sat.hasOverride); - const showDescendantBadge = sat.descendantCount > 0; - const fallbackEditUrl = `https://da.live/edit#/${this.details.org}/${sat.site}${this.details.path}`; - - return html` -
  • - - ${this.renderStatusIcon(sat.status)} - ${sat.hasOverride ? html` - - - ` : nothing} -
  • `; - } - renderConfirm() { if (!this._confirmAction) return nothing; return html` @@ -494,85 +538,6 @@ class DaMsm extends LitElement {
    `; } - renderSyncModeSelect() { - return html` - { this._syncMode = e.target.value; }}> - - - `; - } - - renderActionPicker() { - const groups = []; - - // The picker shows only the optgroups relevant to the active direction. - // The Sync-from-parent switch (or absence of one of the roles) decides - // which direction is active. - if (this._asSatellite && this._isUpwardMode) { - groups.push({ - label: 'From parent', - items: [ - { value: 'sync-from-base', label: 'Sync from base' }, - { value: 'resume-inheritance', label: 'Resume inheritance' }, - ], - }); - } - - if (this._asBase && !this._isUpwardMode) { - groups.push({ - label: 'Inherited sites', - items: [ - { value: 'preview', label: 'Roll out to preview' }, - { value: 'publish', label: 'Roll out to live' }, - { value: 'break', label: 'Cancel inheritance' }, - ], - }); - groups.push({ - label: 'Custom sites', - items: [ - { value: 'sync', label: 'Sync to satellite' }, - { value: 'reset', label: 'Resume inheritance' }, - ], - }); - } - - return html` - this.onActionChange(e.target.value)}> - ${groups.map((group) => html` - - ${group.items.map((opt) => html` - - `)} - - `)} - `; - } - - renderDirectionSwitch() { - if (!this._hasDualRole) return nothing; - return html` - `; - } - renderBreadcrumb() { if (!this._asSatellite) return nothing; @@ -591,88 +556,317 @@ class DaMsm extends LitElement { `; } - renderUpwardSummary() { - // Skip for base-only pages or when a downward action is selected. - if (!this._asSatellite) return nothing; - if (!this._isUpwardMode) return nothing; + // ── Primary buttons: downward (base / dual-downward) ── + // + // Side-by-side, Spectrum-style pill buttons (fill + outline). The chip + // section below shows what they target and how many — no in-button + // subtitle. The `title` attribute carries the tooltip / disabled reason. + + renderPrimaryButtons() { + if (!this._asBase || this._isUpwardMode) return nothing; + + const inheritedCount = this._inherited.length; + if (inheritedCount === 0) return nothing; - const baseLabel = this._asSatellite.baseLabel || this._asSatellite.base; - const targetLabel = this.details.site; - const overrideText = this._hasOverride ? 'Yes — overridden' : 'None — inherited'; - const overrideMuted = !this._hasOverride; + const isInheritedScope = ACTION_SCOPE[this._action] === 'inherited'; + const selectedInherited = this._inherited.filter((s) => this._selected.has(s.site)).length; + // When the picker (in Advanced) is on a custom-scope action, `_selected` + // holds custom sites — but clicking a primary button re-seeds to all + // inherited via runQuickAction, so the button is never disabled in that + // case. + const noSelection = isInheritedScope && selectedInherited === 0; + const disabled = this._busy || noSelection; + + const countLabel = `${inheritedCount} site${inheritedCount !== 1 ? 's' : ''} following base`; + const reason = noSelection ? 'Select at least one site below' : countLabel; + const previewTitle = `Roll out to preview — ${reason}`; + const liveTitle = `Roll out to live — ${reason}`; return html` -
    -
    - Source - ${baseLabel} -
    -
    - Target - ${targetLabel} ${this.renderStatusIcon(this._satStatus)} -
    -
    - Local override - ${overrideText} -
    -
    `; +
    + + +
    `; } - renderChildrenList() { - // The children list only applies to the downward direction. When the - // Sync-from-parent switch is on (or the page is satellite-only), hide - // the list entirely so the dialog focuses on the upward action. + // ── Primary buttons: upward (satellite-only / dual-upward) ── + + renderSatellitePrimaryButtons() { + const baseLabel = this._asSatellite?.baseLabel || this._asSatellite?.base || 'base'; + const canRevert = this._hasOverride === true; + const pullTitle = this._hasOverride + ? `Merge latest from ${baseLabel} into your local copy` + : `Copy the page from ${baseLabel} to your site`; + const revertTitle = `Delete your local copy and follow ${baseLabel} again`; + + return html` +
    + + ${canRevert ? html` + + ` : nothing} +
    `; + } + + // ── Satellite status line (upward view) ── + + renderSatelliteStatusLine() { + if (!this._asSatellite) return nothing; + const overrideText = this._hasOverride + ? 'Yes — has local copy' + : 'None — following base'; + return html` +
    + Local override: + ${overrideText} + ${this._satStatus ? this.renderStatusIcon(this._satStatus) : nothing} +
    `; + } + + // ── Site chips: single unified list grouped by satellite state ── + // + // Two sections are always shown (when non-empty): + // • Following base — satellites with no local copy + // • With local copy — satellites that have an override + // + // Each chip's interactivity is driven by the active action's scope: + // • in scope → `; + } + + if (sat.hasOverride) { + const editUrl = sat.editUrl + || `https://da.live/edit#/${this.details.org}/${sat.site}${this.details.path}`; + return html` + + ${sat.label} + ${dc > 0 ? html`+${dc}` : nothing} + ${sat.status ? this.renderStatusIcon(sat.status) : nothing} + + `; + } + + return html` + + ${sat.label} + ${dc > 0 ? html`+${dc}` : nothing} + ${sat.status ? this.renderStatusIcon(sat.status) : nothing} + `; + } + + // ── Advanced expander (collapsed by default) ── + + renderAdvancedExpander() { + return html` +
    + + ${this._showAdvanced ? html` +
    +

    Pick which action to apply, then choose the sites in the chip list above.

    +
    + ${this.renderActionPicker()} + ${this._isSyncMode ? this.renderSyncModeSelect() : nothing} +
    + ${this.renderAdvancedFooter()} +
    + ` : nothing}
    `; } - renderFooter() { - const showCascade = this._isRecursiveActive - && this._totalDescendants > 0 - && !this._isUpwardMode; - // "Resume inheritance" on a satellite is a no-op if there's no local - // override to remove; disable Apply in that case. - const noOverrideToResume = this._action === 'resume-inheritance' - && this._asSatellite && !this._hasOverride; - const applyDisabled = this._busy - || (this._isUpwardMode ? noOverrideToResume : !this._canApplyDownward); + renderAdvancedFooter() { + const applyDisabled = this._busy || !this._canApplyDownward; + const count = this._directTargets.length; + const label = count > 0 + ? `Apply to ${count} site${count !== 1 ? 's' : ''}` + : 'Apply'; return html`
    - ${showCascade ? html` - ` : nothing} this.apply()} - ?disabled=${applyDisabled}>Apply + ?disabled=${applyDisabled}>${label}
    `; } + // ── App deep-link ── + + renderAppLink() { + return html` + + Manage variants in MSM \u2197 + `; + } + + // ── Direction-flip link (dual role only) ── + + renderDirectionFlipLink() { + if (!this._hasDualRole) return nothing; + const toUpward = !this._isUpwardMode; + const baseLabel = this._asSatellite?.baseLabel || this._asSatellite?.base; + const label = toUpward + ? `Update from parent (${baseLabel}) instead` + : 'Update children instead'; + const arrow = toUpward ? '\u2191' : '\u2193'; + return html` + `; + } + + // ── Action picker (used inside Advanced) ── + + renderActionPicker() { + // The picker only exposes actions the primary buttons don't already cover. + // For base/dual-downward (the only mode that shows Advanced) that's the + // three "manage local copies" actions; preview / publish stay in the + // primary buttons above so they're not duplicated here. + const options = [ + { value: 'break', label: 'Make a local copy on selected sites' }, + { value: 'sync', label: 'Push update to customized sites' }, + { value: 'reset', label: 'Discard local copy on customized sites' }, + ]; + + return html` + this.onActionChange(e.target.value)}> + ${options.map((opt) => html` + + `)} + `; + } + + renderSyncModeSelect() { + return html` + { this._syncMode = e.target.value; }}> + + + `; + } + + // ── Composed views ── + + renderDownwardView() { + return html` + ${this.renderPrimaryButtons()} + ${this.renderSiteChips()}`; + } + + renderUpwardView() { + return html` + ${this.renderSatelliteStatusLine()} + ${this.renderSatellitePrimaryButtons()}`; + } + render() { if (this._loading) { return html`

    ${this._loading}

    `; @@ -682,17 +876,17 @@ class DaMsm extends LitElement { return html`

    No satellite sites configured.

    `; } + const isUpward = this._showUpwardView; + return html` - ${this.renderBreadcrumb()} - ${this.renderDirectionSwitch()} -
    - ${this.renderActionPicker()} - ${this._isSyncMode ? this.renderSyncModeSelect() : nothing} -
    - ${this.renderUpwardSummary()} - ${this.renderChildrenList()} - ${this.renderFooter()} - ${this.renderConfirm()}`; + ${this._asSatellite ? this.renderBreadcrumb() : nothing} + ${isUpward ? this.renderUpwardView() : this.renderDownwardView()} + ${this.renderConfirm()} +
    + ${!isUpward && this._asBase ? this.renderAdvancedExpander() : nothing} + ${this._hasDualRole ? this.renderDirectionFlipLink() : nothing} + ${this.renderAppLink()} +
    `; } } From 451a234a274b16e0251d0ed83a9deec2757d6ff3 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Thu, 21 May 2026 18:05:58 -0400 Subject: [PATCH 13/26] icons fix --- .../plugins/msm/img/S2_Icon_AlertTriangle_20_N.svg | 6 ++++++ .../msm/img/S2_Icon_CheckmarkCircle_20_N.svg | 5 +++++ .../plugins/msm/img/S2_Icon_ChevronRight_20_N.svg | 6 ++++++ .../plugins/msm/img/S2_Icon_ClockPending_20_N.svg | 12 ++++++++++++ tools/plugins/msm/img/S2_Icon_Delete_20_N.svg | 6 ++++++ .../plugins/msm/img/S2_Icon_ExperienceAdd_20_N.svg | 8 ++++++++ .../msm/img/S2_Icon_ExperiencePreview_20_N.svg | 9 +++++++++ tools/plugins/msm/img/S2_Icon_Publish_20_N.svg | 4 ++++ tools/plugins/msm/msm.js | 14 ++++++++------ 9 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 tools/plugins/msm/img/S2_Icon_AlertTriangle_20_N.svg create mode 100644 tools/plugins/msm/img/S2_Icon_CheckmarkCircle_20_N.svg create mode 100644 tools/plugins/msm/img/S2_Icon_ChevronRight_20_N.svg create mode 100644 tools/plugins/msm/img/S2_Icon_ClockPending_20_N.svg create mode 100644 tools/plugins/msm/img/S2_Icon_Delete_20_N.svg create mode 100644 tools/plugins/msm/img/S2_Icon_ExperienceAdd_20_N.svg create mode 100644 tools/plugins/msm/img/S2_Icon_ExperiencePreview_20_N.svg create mode 100644 tools/plugins/msm/img/S2_Icon_Publish_20_N.svg diff --git a/tools/plugins/msm/img/S2_Icon_AlertTriangle_20_N.svg b/tools/plugins/msm/img/S2_Icon_AlertTriangle_20_N.svg new file mode 100644 index 0000000..1e561f8 --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_AlertTriangle_20_N.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/tools/plugins/msm/img/S2_Icon_CheckmarkCircle_20_N.svg b/tools/plugins/msm/img/S2_Icon_CheckmarkCircle_20_N.svg new file mode 100644 index 0000000..d31eee8 --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_CheckmarkCircle_20_N.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/tools/plugins/msm/img/S2_Icon_ChevronRight_20_N.svg b/tools/plugins/msm/img/S2_Icon_ChevronRight_20_N.svg new file mode 100644 index 0000000..caa8c36 --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_ChevronRight_20_N.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/tools/plugins/msm/img/S2_Icon_ClockPending_20_N.svg b/tools/plugins/msm/img/S2_Icon_ClockPending_20_N.svg new file mode 100644 index 0000000..91dd39a --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_ClockPending_20_N.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tools/plugins/msm/img/S2_Icon_Delete_20_N.svg b/tools/plugins/msm/img/S2_Icon_Delete_20_N.svg new file mode 100644 index 0000000..2eb9d24 --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_Delete_20_N.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/tools/plugins/msm/img/S2_Icon_ExperienceAdd_20_N.svg b/tools/plugins/msm/img/S2_Icon_ExperienceAdd_20_N.svg new file mode 100644 index 0000000..b5b07a6 --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_ExperienceAdd_20_N.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tools/plugins/msm/img/S2_Icon_ExperiencePreview_20_N.svg b/tools/plugins/msm/img/S2_Icon_ExperiencePreview_20_N.svg new file mode 100644 index 0000000..03f4ba3 --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_ExperiencePreview_20_N.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tools/plugins/msm/img/S2_Icon_Publish_20_N.svg b/tools/plugins/msm/img/S2_Icon_Publish_20_N.svg new file mode 100644 index 0000000..efd467e --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_Publish_20_N.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tools/plugins/msm/msm.js b/tools/plugins/msm/msm.js index 0f15da7..852384f 100644 --- a/tools/plugins/msm/msm.js +++ b/tools/plugins/msm/msm.js @@ -39,12 +39,14 @@ const ACTION_SCOPE = { // (see helpers/parseDeepLink in tools/apps/msm/msm.js). const MSM_APP_URL = 'https://da.live/app/aemsites/da-blog-tools/tools/apps/msm/msm'; -// Icons are referenced as absolute URLs to da.live so they resolve correctly -// even though the plugin is served from a different origin (e.g. da-blog-tools). -// All icons in the plugin use the Spectrum 2 workflow icons hosted there via -// `` — see -// `renderStatusIcon` for the canonical pattern. -const ICON_BASE = 'https://da.live/blocks/edit/img'; +// All icons in the plugin use Spectrum 2 workflow icons (originally from +// https://da.live/blocks/edit/img) via +// `` +// — see `renderStatusIcon` for the canonical pattern. The icons are vendored +// into ./img/ because Chromium blocks cross-origin SVG `` for +// security ("Unsafe attempt to load URL …"), and the plugin is served from a +// different origin (aem.page) than da.live in production. +const ICON_BASE = './img'; // Component / style pipeline. We pull two element families to mirror OOTB: // • sl-* — older "Super Lite" controls; the Apply button uses , From dac1ff0df02d2750584e49fab5263434720cf908 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Thu, 21 May 2026 18:38:00 -0400 Subject: [PATCH 14/26] plugin updates --- tools/plugins/msm/msm.css | 26 +++++++++ tools/plugins/msm/msm.js | 118 ++++++++++++++++++++++++++++++++++---- 2 files changed, 132 insertions(+), 12 deletions(-) diff --git a/tools/plugins/msm/msm.css b/tools/plugins/msm/msm.css index bedb22f..996abcd 100644 --- a/tools/plugins/msm/msm.css +++ b/tools/plugins/msm/msm.css @@ -424,6 +424,32 @@ margin: 0; } +/* --- Inline cascade toggle --- + * Sits between the primary buttons and the chip section. Governs whether + * preview/publish push through the inheritance tree to nested satellites, + * or stop at the direct chips below. Only rendered when nested sites + * exist; otherwise this row collapses entirely. */ + +.cascade-toggle-inline { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--s2-gray-700, #4b4b4b); +} + +.cascade-toggle-inline input[type="checkbox"] { + margin: 0; + width: 14px; + height: 14px; + accent-color: var(--s2-gray-800, #292929); + cursor: pointer; +} + +.cascade-toggle-inline-label { + cursor: pointer; +} + /* --- Status icons --- */ .result-icon { diff --git a/tools/plugins/msm/msm.js b/tools/plugins/msm/msm.js index 852384f..72e4df7 100644 --- a/tools/plugins/msm/msm.js +++ b/tools/plugins/msm/msm.js @@ -4,6 +4,7 @@ import { LitElement, html, nothing } from 'da-lit'; import DA_SDK from 'https://da.live/nx/utils/sdk.js'; import { getSiteConfig, + getSubtreeSatellites, isPageLocal, checkOverrides, setSdkFetch as setConfigSdkFetch, @@ -103,6 +104,7 @@ class DaMsm extends LitElement { _hasOverride: { state: true }, _satStatus: { state: true }, _showAdvanced: { state: true }, + _includeDescendants: { state: true }, }; connectedCallback() { @@ -114,6 +116,10 @@ class DaMsm extends LitElement { this._syncMode = SYNC_MODE.merge; this._busy = false; this._showAdvanced = false; + // Cascade preview/publish through the inheritance tree by default — see + // the comment above runAction's preview/publish case. Authors can opt out + // via the checkbox in "More options" to limit a rollout to direct sites. + this._includeDescendants = true; this.loadConfig(); } @@ -256,6 +262,15 @@ class DaMsm extends LitElement { this._showAdvanced = opening; } + _setIncludeDescendants(value) { + // Toggle persists for the session — authors who explicitly opt out of + // cascade shouldn't have it silently re-enabled by subsequent renders. + this._includeDescendants = !!value; + // Clear stale per-chip success/error markers since the previous run's + // results no longer match the new scope. + this.clearStatuses(); + } + _getAppDeepLink() { const { org, site, path } = this.details; const params = new URLSearchParams({ org, site, path }); @@ -363,7 +378,6 @@ class DaMsm extends LitElement { const { org, site, path } = this.details; const directTargets = this._directTargets; - const targetSites = directTargets.map((s) => s.site); directTargets.forEach((s) => this.updateSatStatus(s.site, STATUS.pending)); @@ -371,13 +385,37 @@ class DaMsm extends LitElement { case 'preview': case 'publish': { const fn = action === 'publish' ? publishSatellite : previewSatellite; + // Cascade (when `_includeDescendants` is on): each direct target's + // subtree gets the rollout too, because content inheritance flows + // through the whole tree (A → B → C). Without cascading, C would + // render stale content even after A is updated. When the author has + // opted out of cascade via the "More options" checkbox, the action + // stops at the direct sites. Per-direct status on the chips is + // aggregated all-or-nothing across its subtree since descendants + // aren't visible in the chip list. + const subtreeMap = new Map(); + await Promise.all(directTargets.map(async (target) => { + const subtree = this._includeDescendants + ? await getSubtreeSatellites(org, target.site) + : []; + subtreeMap.set( + target.site, + [target.site, ...subtree.map((s) => s.site)], + ); + })); + const sitesToCall = [...new Set([...subtreeMap.values()].flat())]; const results = await Promise.allSettled( - targetSites.map((satSite) => fn(org, satSite, path)), + sitesToCall.map((satSite) => fn(org, satSite, path)), ); + const statusBySite = new Map(); results.forEach((r, idx) => { - const satSite = targetSites[idx]; const ok = r.status === 'fulfilled' && !r.value?.error; - this.updateSatStatus(satSite, ok ? STATUS.success : STATUS.error); + statusBySite.set(sitesToCall[idx], ok); + }); + directTargets.forEach((target) => { + const sites = subtreeMap.get(target.site) || [target.site]; + const allOk = sites.every((s) => statusBySite.get(s) === true); + this.updateSatStatus(target.site, allOk ? STATUS.success : STATUS.error); }); break; } @@ -571,15 +609,26 @@ class DaMsm extends LitElement { if (inheritedCount === 0) return nothing; const isInheritedScope = ACTION_SCOPE[this._action] === 'inherited'; - const selectedInherited = this._inherited.filter((s) => this._selected.has(s.site)).length; // When the picker (in Advanced) is on a custom-scope action, `_selected` // holds custom sites — but clicking a primary button re-seeds to all - // inherited via runQuickAction, so the button is never disabled in that - // case. - const noSelection = isInheritedScope && selectedInherited === 0; + // inherited via runQuickAction. When already in inherited scope, the + // current selection is what runs (no re-seed). + const willRunOn = isInheritedScope + ? this._inherited.filter((s) => this._selected.has(s.site)) + : this._inherited; + const directCount = willRunOn.length; + const cascadeCount = this._includeDescendants + ? willRunOn.reduce((acc, s) => acc + (s.descendantCount || 0), 0) + : 0; + const totalCount = directCount + cascadeCount; + const noSelection = isInheritedScope && directCount === 0; const disabled = this._busy || noSelection; - const countLabel = `${inheritedCount} site${inheritedCount !== 1 ? 's' : ''} following base`; + const directLabel = `${directCount} site${directCount !== 1 ? 's' : ''} following base`; + const cascadeLabel = cascadeCount > 0 + ? ` + ${cascadeCount} nested = ${totalCount} total` + : ''; + const countLabel = `${directLabel}${cascadeLabel}`; const reason = noSelection ? 'Select at least one site below' : countLabel; const previewTitle = `Roll out to preview — ${reason}`; const liveTitle = `Roll out to live — ${reason}`; @@ -684,6 +733,11 @@ class DaMsm extends LitElement { const custom = this._custom; if (!inherited.length && !custom.length) return nothing; + // Note: cascade communication lives in the inline toggle above the chips + // (renderCascadeToggleInline) and in the per-chip "+N" badges below — + // the heading stays a plain section label so the three signals don't + // compete for the same screen real estate. + return html` ${inherited.length ? html`
    @@ -709,6 +763,18 @@ class DaMsm extends LitElement { const dc = sat.descendantCount || 0; const statusClass = sat.status ? `status-${sat.status}` : ''; + // When this chip is in scope for a recursive action (preview/publish) + // AND the author hasn't disabled cascade in "More options", the "+N" + // badge represents sites that will ALSO receive the rollout. Otherwise + // it just describes the site's subtree. + const cascades = inScope + && RECURSIVE_ACTIONS.has(this._action) + && this._includeDescendants; + const dcSuffix = dc === 1 ? '' : 's'; + const dcTitle = cascades + ? `Also rolls out to ${dc} nested site${dcSuffix}` + : `${dc} nested site${dcSuffix}`; + if (inScope) { return html` `; } @@ -732,7 +798,7 @@ class DaMsm extends LitElement { rel="noopener" title="Open in editor"> ${sat.label} - ${dc > 0 ? html`+${dc}` : nothing} + ${dc > 0 ? html`+${dc}` : nothing} ${sat.status ? this.renderStatusIcon(sat.status) : nothing} ${sat.label} - ${dc > 0 ? html`+${dc}` : nothing} + ${dc > 0 ? html`+${dc}` : nothing} ${sat.status ? this.renderStatusIcon(sat.status) : nothing} `; } @@ -773,6 +839,33 @@ class DaMsm extends LitElement {
    `; } + // Inline checkbox that governs whether preview/publish cascade through the + // inheritance tree. Lives right below the primary buttons so the scope + // control sits next to the action it modifies — authors can see and adjust + // it in a single glance instead of opening "More options". Default on + // (matches the chip-section heading and button-tooltip totals). Hidden when + // the page has no nested satellites — otherwise it would be a no-op. + renderCascadeToggleInline() { + if (!this._asBase || this._isUpwardMode) return nothing; + const totalDescendants = this._inherited.reduce( + (acc, s) => acc + (s.descendantCount || 0), + 0, + ); + if (totalDescendants === 0) return nothing; + const id = 'msm-cascade-toggle'; + const sitesWord = `nested site${totalDescendants === 1 ? '' : 's'}`; + return html` +
    + this._setIncludeDescendants(e.target.checked)}> + +
    `; + } + renderAdvancedFooter() { const applyDisabled = this._busy || !this._canApplyDownward; const count = this._directTargets.length; @@ -860,6 +953,7 @@ class DaMsm extends LitElement { renderDownwardView() { return html` ${this.renderPrimaryButtons()} + ${this.renderCascadeToggleInline()} ${this.renderSiteChips()}`; } From 8c2539b5bc25557dff09238004ff9430ac30c31c Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Thu, 21 May 2026 18:43:43 -0400 Subject: [PATCH 15/26] ref support added --- tools/plugins/msm/msm.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tools/plugins/msm/msm.js b/tools/plugins/msm/msm.js index 72e4df7..ddbd5ee 100644 --- a/tools/plugins/msm/msm.js +++ b/tools/plugins/msm/msm.js @@ -272,8 +272,15 @@ class DaMsm extends LitElement { } _getAppDeepLink() { - const { org, site, path } = this.details; + const { + org, site, path, ref, + } = this.details; const params = new URLSearchParams({ org, site, path }); + // `ref` routes the DA shell at da.live/app/... to the matching branch + // of the tool's source so feature-branch authoring stays on that branch + // after hand-off. Only append when present so the default (main) link + // stays clean. + if (ref) params.set('ref', ref); return `${MSM_APP_URL}?${params.toString()}`; } @@ -1010,9 +1017,11 @@ export default function render(details) { const { context, actions } = await DA_SDK; // The DA Prepare host posts `context = { view, org, site, ref, path }`. // Fall back to `repo` for hosts that still use the older field name. - const { org, path } = context; + const { org, path, ref } = context; const site = context.site || context.repo; - console.log('[MSM Plugin] Init context:', { org, site, path }); + console.log('[MSM Plugin] Init context:', { + org, site, path, ref, + }); console.log('[MSM Plugin] actions.daFetch available?', typeof actions?.daFetch); // Wire host-supplied daFetch into both helper modules. @@ -1030,7 +1039,9 @@ export default function render(details) { } const cmp = document.createElement('da-msm'); - cmp.details = { org, site, path }; + cmp.details = { + org, site, path, ref, + }; document.body.append(cmp); } catch (error) { console.error('[MSM Plugin] Initialization error:', error); From ce7c368cfe27f9760e0bc6c0ea464e704176a8d0 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Fri, 22 May 2026 09:55:30 -0400 Subject: [PATCH 16/26] action labels updated --- tools/apps/msm/README.md | 20 +++--- tools/apps/msm/helpers/action-panel.js | 85 ++++++++++++++------------ 2 files changed, 57 insertions(+), 48 deletions(-) diff --git a/tools/apps/msm/README.md b/tools/apps/msm/README.md index f483bea..f0d3f52 100644 --- a/tools/apps/msm/README.md +++ b/tools/apps/msm/README.md @@ -19,20 +19,24 @@ The role is determined automatically based on the org's MSM configuration and th Each page on a satellite is either: -- **Inherited** — No local copy exists on the satellite; it inherits content from the base site. -- **Custom (overridden)** — A local copy exists on the satellite, breaking inheritance. +- **Following base** — No local copy exists on the satellite; it inherits content from the base site. +- **With local copy** — A local copy exists on the satellite (customized), breaking inheritance. -Actions are scoped by this status. For example, "Preview" and "Publish" target inherited satellites, while "Sync" and "Resume inheritance" target satellites with custom overrides. +Actions are scoped by this status. For example, roll out actions target sites following base, while push/discard actions target sites with a local copy. ### Actions +Terminology matches the [MSM Prepare plugin](../../plugins/msm/README.md). + | Action | Scope | Description | |---|---|---| -| **Preview** | Inherited | Triggers a preview of the page on inherited satellites | -| **Publish** | Inherited | Publishes the page to inherited satellites | -| **Cancel inheritance** | Inherited | Creates a local copy on the satellite, breaking inheritance | -| **Sync to satellite** | Custom | Updates the satellite's override — either via **Merge** (preserves satellite edits) or **Override** (replaces with base content) | -| **Resume inheritance** | Custom | Deletes the satellite override and re-previews/publishes if the page was previously live | +| **Roll out to preview** | Following base | Triggers a preview of the page on inherited satellites | +| **Roll out to live** | Following base | Publishes the page to inherited satellites | +| **Make a local copy on selected sites** | Following base | Creates a local copy on the satellite, breaking inheritance | +| **Push update to customized sites** | With local copy | Updates the satellite's override — **Keep local edits (merge)** or **Replace with base (override)** | +| **Discard local copy on customized sites** | With local copy | Deletes the satellite override and re-previews/publishes if the page was previously live | +| **Pull latest from base** | Satellite (upward) | Copies or merges content from the parent base into this site | +| **Revert to base** | Satellite (upward) | Deletes the local copy so the page follows the base again | ## Configuration diff --git a/tools/apps/msm/helpers/action-panel.js b/tools/apps/msm/helpers/action-panel.js index 6a66eb0..3593fec 100644 --- a/tools/apps/msm/helpers/action-panel.js +++ b/tools/apps/msm/helpers/action-panel.js @@ -25,40 +25,35 @@ const SUCCESS_ICON = html``; const EDIT_ICON = html``; +// Labels match tools/plugins/msm/msm.js (Prepare plugin) for consistent author wording. const DOWNWARD_ACTIONS = [ { - heading: 'Inherited sites', + heading: 'Following base', items: [ { value: 'preview', label: 'Roll out to preview' }, { value: 'publish', label: 'Roll out to live' }, - { value: 'break', label: 'Cancel inheritance' }, + { value: 'break', label: 'Make a local copy on selected sites' }, ], }, { - heading: 'Custom sites', + heading: 'With local copy', items: [ - { value: 'sync', label: 'Sync to satellite' }, - { value: 'reset', label: 'Resume inheritance' }, + { value: 'sync', label: 'Push update to customized sites' }, + { value: 'reset', label: 'Discard local copy on customized sites' }, ], }, ]; // Upward action lists, scoped to the page's inheritance category. The picker // surfaces only the actions that make sense for the selection: -// - inherited : the page lives on an ancestor; you can pull it down or -// materialize a local copy that breaks the inheritance link. -// - overridden : the page already has a local copy that overrides the -// base; you can refresh from base or drop the local copy. -// - local : the page exists only on this site (no base counterpart); -// no upward operations are meaningful. -// Inherited pages have no local copy here, so 'Sync from base' would just -// materialize one — identical to 'Cancel inheritance'. Only the latter is -// offered to keep the user's intent unambiguous. +// - inherited : following base; Pull latest from base materializes a local copy. +// - overridden : has a local copy; pull merges or override replaces from base. +// - local : no base counterpart; no upward operations are meaningful. const UPWARD_ACTIONS_INHERITED = [ { heading: 'From parent', items: [ - { value: 'cancel-inheritance', label: 'Cancel inheritance' }, + { value: 'cancel-inheritance', label: 'Pull latest from base' }, ], }, ]; @@ -67,8 +62,8 @@ const UPWARD_ACTIONS_OVERRIDDEN = [ { heading: 'From parent', items: [ - { value: 'sync-from-base', label: 'Sync from base' }, - { value: 'resume-inheritance', label: 'Resume inheritance' }, + { value: 'sync-from-base', label: 'Pull latest from base' }, + { value: 'resume-inheritance', label: 'Revert to base' }, ], }, ]; @@ -81,10 +76,15 @@ const UPWARD_ACTIONS_LOCAL = [ ]; const SYNC_OPTIONS = [ - { value: 'merge', label: 'Merge' }, - { value: 'override', label: 'Override' }, + { value: 'merge', label: 'Keep local edits (merge)' }, + { value: 'override', label: 'Replace with base (override)' }, ]; +const SCOPE_DISPLAY = { + inherited: 'following base', + custom: 'with local copy', +}; + const ACTION_SCOPE = { preview: 'inherited', publish: 'inherited', @@ -508,15 +508,16 @@ class MsmActionPanel extends LitElement { const parts = Object.entries(counts).map(([a, c]) => { const label = `${getActionLabel(a)} ${c} page${c > 1 ? 's' : ''}`; const scope = ACTION_SCOPE[a]; - return scope ? `${label} (${scope} sites only)` : label; + const scopeLabel = scope ? SCOPE_DISPLAY[scope] : null; + return scopeLabel ? `${label} (${scopeLabel} sites only)` : label; }); const satCount = this._selectedSats.size; const satSuffix = `across ${satCount} direct satellite${satCount !== 1 ? 's' : ''}`; - const skipNote = ' Satellites that don\'t match the action scope will be skipped.'; + const skipNote = ' Sites that don\'t match the action scope will be skipped.'; const recursiveActive = this._includeDescendants && Object.keys(counts).some((a) => RECURSIVE_ACTIONS.has(a)); const recursiveNote = recursiveActive - ? ` Including ${this._totalDescendants} descendant site${this._totalDescendants !== 1 ? 's' : ''} (Preview/Publish only).` + ? ` Also roll out to ${this._totalDescendants} nested site${this._totalDescendants !== 1 ? 's' : ''}.` : ''; return `${parts.join(', ')} ${satSuffix}.${skipNote}${recursiveNote} Continue?`; } @@ -652,14 +653,14 @@ class MsmActionPanel extends LitElement { if (action === 'reset') { this._confirmAction = { - message: 'Resume inheritance? This deletes local overrides for selected satellites.', + message: 'Discard local copy on customized sites? Removes the satellite override.', onConfirm: () => this.doExecuteSingle(page, action), }; return; } if (action === 'resume-inheritance') { this._confirmAction = { - message: `Resume inheritance for ${this.site}? This deletes the local override of ${page.name} so it inherits from ${this.parentBase}.`, + message: 'Revert to base? This deletes the local copy on this satellite.', onConfirm: () => this.doExecuteSingle(page, action), }; return; @@ -667,7 +668,7 @@ class MsmActionPanel extends LitElement { if (action === 'cancel-inheritance') { const src = this._resolveSourceSite(page); this._confirmAction = { - message: `Cancel inheritance for ${page.name} on ${this.site}? This creates a local copy from ${src}, breaking the inheritance link.`, + message: `Copy ${page.name} from ${src} to ${this.site}? This creates a local copy on your site.`, onConfirm: () => this.doExecuteSingle(page, action), }; return; @@ -719,14 +720,14 @@ class MsmActionPanel extends LitElement { const syncMode = this.getPageSyncMode(page.path); if (action === 'reset') { this._confirmAction = { - message: `Resume inheritance for ${page.name}? This deletes local overrides.`, + message: `Discard local copy on customized sites for ${page.name}? Removes the satellite override.`, onConfirm: () => this.doExecuteSingle(page, action, this._selectedSats, syncMode), }; return; } if (action === 'resume-inheritance') { this._confirmAction = { - message: `Resume inheritance for ${page.name} on ${this.site}? This deletes the local override so it inherits from ${this.parentBase}.`, + message: 'Revert to base? This deletes the local copy on this satellite.', onConfirm: () => this.doExecuteSingle(page, action, this._selectedSats, syncMode), }; return; @@ -734,7 +735,7 @@ class MsmActionPanel extends LitElement { if (action === 'cancel-inheritance') { const src = this._resolveSourceSite(page); this._confirmAction = { - message: `Cancel inheritance for ${page.name} on ${this.site}? This creates a local copy from ${src}, breaking the inheritance link.`, + message: `Copy ${page.name} from ${src} to ${this.site}? This creates a local copy on your site.`, onConfirm: () => this.doExecuteSingle(page, action, this._selectedSats, syncMode), }; return; @@ -855,18 +856,22 @@ class MsmActionPanel extends LitElement { renderDirectionSwitch() { if (!this._hasDualRole) return nothing; const checked = this._isUpwardMode; + const baseLabel = this.parentBase || 'parent'; + const switchLabel = checked + ? 'Update children instead' + : `Update from parent (${baseLabel}) instead`; return html` `; } @@ -899,9 +904,9 @@ class MsmActionPanel extends LitElement { const hasOverride = selfEntry?.hasOverride === true; const inheritedFrom = selfEntry?.inheritedFrom || null; const effectiveSource = selfEntry?.sourceSite || this.parentBase || source; - let overrideText = 'No'; - if (hasOverride) overrideText = 'Yes'; - else if (inheritedFrom) overrideText = `No \u2014 inherited from ${inheritedFrom}`; + let overrideText = 'None \u2014 following base'; + if (hasOverride) overrideText = 'Yes \u2014 has local copy'; + else if (inheritedFrom) overrideText = 'None \u2014 following base'; return html`
    Source${effectiveSource}${pagePath}
    @@ -940,7 +945,7 @@ class MsmActionPanel extends LitElement { this._includeDescendants = e.target.checked; this._resetExecution(); }} /> - Cascade to ${this._totalDescendants} nested site${this._totalDescendants !== 1 ? 's' : ''} + Also roll out to ${this._totalDescendants} nested site${this._totalDescendants !== 1 ? 's' : ''} ` : html``} ${inherited.length ? html`
    -
    Inherited
    +
    Following base
      ${inherited.map((sat) => this.renderSatRow(sat, scope !== 'inherited'))}
    @@ -1065,7 +1070,7 @@ class MsmActionPanel extends LitElement { ` : nothing} ${custom.length ? html`
    -
    Custom
    +
    With local copy
      ${custom.map((sat) => this.renderSatRow(sat, scope !== 'custom', true))}
    @@ -1252,11 +1257,11 @@ class MsmActionPanel extends LitElement { ${showOverrides ? html`
    - ${summary.inherited} inherited + ${summary.inherited} following base ${summary.custom > 0 ? html` - ${summary.custom} custom + ${summary.custom} with local copy ` : nothing}
    - ${this._isSatellite ? nothing : html``} + ${showOverrides ? html`` : nothing} @@ -726,19 +1213,19 @@ class MsmActionPanel extends LitElement { @change=${() => this.toggleAllPages()} /> - ${this._isSatellite ? nothing : html``} + ${showOverrides ? html`` : nothing} - ${this.pages.map((page) => this.renderPageRow(page))} + ${this.pages.map((page) => this.renderPageRow(page, showOverrides))}
    PageOverridesOverridesAction
    `; } - renderPageRow(page) { + renderPageRow(page, showOverrides) { const isExpanded = this._expandedRows.has(page.path); const summary = this.getOverrideSummary(page.path); const action = this.getPageAction(page.path); @@ -754,15 +1241,15 @@ class MsmActionPanel extends LitElement {
    this.toggleRow(page.path)}> ${page.name} - ${this._isSatellite ? nothing : html` + ${showOverrides ? html` - `} + ` : nothing}
    - ${this._isSatellite ? nothing : html` + ${showOverrides ? html` ${summary.inherited} inherited @@ -773,17 +1260,17 @@ class MsmActionPanel extends LitElement { ` : nothing} - `} + ` : nothing}
    ${this.renderPicker( `page-${page.path}`, '', action, - this._actionOptions, + this._actionOptionsForPage(page), (v) => this.setPageAction(page.path, v), )} -
    +
    ${this.renderPicker( `page-sync-${page.path}`, '', @@ -797,11 +1284,11 @@ class MsmActionPanel extends LitElement {
    this.executeRow(page)} - ?disabled=${this._busy || !this.hasApplicableSats(page.path, action)}>Apply + ?disabled=${this._busy || !this._canApplySingle(page)}>Apply
    - ${isExpanded ? this.renderExpandedRow(page) : nothing} + ${isExpanded && !this._isUpwardMode ? this.renderExpandedRow(page) : nothing} `; } @@ -810,10 +1297,11 @@ class MsmActionPanel extends LitElement { .filter((o) => this._selectedSats.has(o.site)); const inherited = overrides.filter((o) => !o.hasOverride); const custom = overrides.filter((o) => o.hasOverride); + const colCount = (!this._isUpwardMode && this._hasDownwardActions) ? 5 : 4; return html` - +
    ${inherited.length ? html`
    @@ -836,7 +1324,7 @@ class MsmActionPanel extends LitElement {
  • ${this.statusIcon(this._taskStatuses.get(`${page.path}:${sat.site}`)?.status)} ${sat.label} - + ${EDIT_ICON}
  • @@ -871,7 +1359,7 @@ class MsmActionPanel extends LitElement {
      ${[...this._taskStatuses.entries()].map(([key, { status, error }]) => { const [pagePath, satSite] = key.split(':'); - const pageName = pagePath.split('/').pop().replace('.html', ''); + const pageName = pagePath.split('/').pop().replace(/\.[^/.]+$/, ''); const satLabel = this.satellites?.[satSite]?.label || satSite; return html`
    • diff --git a/tools/apps/msm/helpers/api.js b/tools/apps/msm/helpers/api.js index acdd5ac..7cf3318 100644 --- a/tools/apps/msm/helpers/api.js +++ b/tools/apps/msm/helpers/api.js @@ -4,6 +4,21 @@ const DA_ORIGIN = 'https://admin.da.live'; const AEM_ADMIN = 'https://admin.hlx.page'; const MAX_CONCURRENT = 5; +export const ACTIONABLE_EXTENSIONS = new Set(['html', 'json', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf']); + +export function isActionableItem(item) { + return !item.isFolder && !item.isSite && ACTIONABLE_EXTENSIONS.has(item.ext); +} + +function stripExtension(filePath) { + return filePath.replace(/\.[^/.]+$/, ''); +} + +function getExtension(filePath) { + const match = filePath.match(/\.([^/.]+)$/); + return match ? match[1] : ''; +} + let daFetchFn; async function ensureDaFetch() { @@ -82,6 +97,106 @@ function resolveBaseSites(rows) { return [...baseSites.values()].filter((b) => Object.keys(b.satellites).length > 0); } +// ────────────────────────────────────────────── +// Multi-level inheritance helpers +// ────────────────────────────────────────────── + +function getParentRow(rows, site) { + return rows.find((row) => row.satellite === site); +} + +function getBaseLabel(rows, site) { + const labelRow = rows.find((row) => row.base === site && !row.satellite); + return labelRow?.title; +} + +function getDirectChildren(rows, site) { + return rows + .filter((row) => row.base === site && row.satellite) + .map((row) => ({ site: row.satellite, label: row.title || row.satellite })); +} + +function walkSubtree(rows, rootSite, visited = new Set()) { + if (visited.has(rootSite)) return []; + visited.add(rootSite); + const children = getDirectChildren(rows, rootSite); + return children.flatMap((child) => [ + child, + ...walkSubtree(rows, child.site, visited), + ]); +} + +function walkChain(rows, site) { + const chain = []; + const visited = new Set(); + let current = site; + while (current && !visited.has(current)) { + visited.add(current); + const parentRow = getParentRow(rows, current); + if (!parentRow) break; + chain.unshift({ + site: parentRow.base, + label: getBaseLabel(rows, parentRow.base) || parentRow.base, + }); + current = parentRow.base; + } + return chain; +} + +export function getInheritanceChain(config, site) { + return walkChain(config?.rows || [], site); +} + +export function getSubtreeSites(config, rootSite) { + return walkSubtree(config?.rows || [], rootSite); +} + +export function getDescendantCount(config, site) { + return getSubtreeSites(config, site).length; +} + +export function getSiteRoles(config, site) { + const rows = config?.rows || []; + const children = getDirectChildren(rows, site); + const parentRow = getParentRow(rows, site); + const result = {}; + if (children.length) { + const satellites = children.reduce((acc, child) => { + acc[child.site] = { + label: child.label, + descendantCount: walkSubtree(rows, child.site).length, + }; + return acc; + }, {}); + result.asBase = { + baseLabel: getBaseLabel(rows, site), + satellites, + }; + } + if (parentRow) { + result.asSatellite = { + base: parentRow.base, + baseLabel: getBaseLabel(rows, parentRow.base) || parentRow.base, + chain: walkChain(rows, site), + }; + } + return result; +} + +export function expandSatellitesWithSubtree(config, directSatellites) { + if (!config || !directSatellites) return directSatellites || {}; + const expanded = { ...directSatellites }; + Object.keys(directSatellites).forEach((siteName) => { + const subtree = getSubtreeSites(config, siteName); + subtree.forEach((node) => { + if (!expanded[node.site]) { + expanded[node.site] = { label: node.label }; + } + }); + }); + return expanded; +} + export async function fetchMsmConfig(org) { if (configCache[org]) return configCache[org]; @@ -91,7 +206,7 @@ export async function fetchMsmConfig(org) { const baseSites = resolveBaseSites(rows); if (!baseSites.length) return null; - const config = { baseSites }; + const config = { baseSites, rows }; configCache[org] = config; return config; } @@ -121,15 +236,66 @@ export async function listFolder(org, site, path = '/') { }); } +// ────────────────────────────────────────────── +// Inheritance-aware folder listing +// ────────────────────────────────────────────── + +// Lists a folder for a satellite site and merges in inherited entries from the +// ancestor chain. Returns the same item shape as `listFolder` with three extra +// fields on every item: +// - sourceSite : where the file actually lives (current site or an ancestor) +// - inheritedFrom : null when local, ancestor site name when inherited +// - hasLocalOverride : true iff the path exists in both the current site AND +// in some ancestor (i.e. the local file overrides a base) +// Closest-source-wins: when an item exists at multiple levels, the level +// nearest to the current site (lowest in the chain) decides the source. +export async function listFolderWithInheritance(org, site, path, msmConfig) { + const chain = msmConfig ? getInheritanceChain(msmConfig, site) : []; + if (!chain.length) { + const items = await listFolder(org, site, path); + return items.map((i) => ({ + ...i, + sourceSite: site, + inheritedFrom: null, + hasLocalOverride: false, + })); + } + + // walk[0] = self; walk[1..] = ancestors in nearest-first order. + const walk = [{ site }, ...chain.slice().reverse()]; + const lists = await Promise.all( + walk.map((node) => listFolder(org, node.site, path).catch(() => [])), + ); + + const seen = new Map(); + lists.forEach((items, idx) => { + const node = walk[idx]; + const isLocal = idx === 0; + items.forEach((item) => { + if (seen.has(item.path)) return; + seen.set(item.path, { + ...item, + site, + sourceSite: node.site, + inheritedFrom: isLocal ? null : node.site, + hasLocalOverride: isLocal + && lists.slice(1).some((arr) => arr.some((i) => i.path === item.path)), + }); + }); + }); + + return [...seen.values()]; +} + // ────────────────────────────────────────────── // Override checking // ────────────────────────────────────────────── -export async function checkPageOverrides(org, satellites, pagePath) { +export async function checkPageOverrides(org, satellites, pagePath, ext = 'html') { const entries = Object.entries(satellites); const results = await Promise.all( entries.map(async ([site, info]) => { - const url = `${DA_ORIGIN}/source/${org}/${site}${pagePath}.html`; + const url = `${DA_ORIGIN}/source/${org}/${site}${pagePath}.${ext}`; const resp = await daFetch(url, { method: 'HEAD' }); return { site, label: info.label, hasOverride: resp.ok }; }), @@ -141,8 +307,8 @@ export async function checkPageOverrides(org, satellites, pagePath) { // Preview / Publish // ────────────────────────────────────────────── -export async function previewSatellite(org, satellite, pagePath) { - const aemPath = pagePath.replace(/\.html$/, ''); +export async function previewSatellite(org, satellite, pagePath, ext = 'html') { + const aemPath = ext === 'html' ? pagePath : `${pagePath}.${ext}`; const url = `${AEM_ADMIN}/preview/${org}/${satellite}/main${aemPath}`; const resp = await daFetch(url, { method: 'POST' }); if (!resp.ok) { @@ -152,8 +318,8 @@ export async function previewSatellite(org, satellite, pagePath) { return resp.json(); } -export async function publishSatellite(org, satellite, pagePath) { - const aemPath = pagePath.replace(/\.html$/, ''); +export async function publishSatellite(org, satellite, pagePath, ext = 'html') { + const aemPath = ext === 'html' ? pagePath : `${pagePath}.${ext}`; const url = `${AEM_ADMIN}/live/${org}/${satellite}/main${aemPath}`; const resp = await daFetch(url, { method: 'POST' }); if (!resp.ok) { @@ -167,31 +333,44 @@ export async function publishSatellite(org, satellite, pagePath) { // Override management // ────────────────────────────────────────────── -export async function createOverride(org, baseSite, satellite, pagePath) { - const basePath = `${DA_ORIGIN}/source/${org}/${baseSite}${pagePath}.html`; +const EXT_MIME_TYPES = { + html: 'text/html', + json: 'application/json', + svg: 'image/svg+xml', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + pdf: 'application/pdf', +}; + +export async function createOverride(org, baseSite, satellite, pagePath, ext = 'html') { + const basePath = `${DA_ORIGIN}/source/${org}/${baseSite}${pagePath}.${ext}`; const resp = await daFetch(basePath); if (!resp.ok) return { error: `Failed to fetch base content (${resp.status})` }; - const content = await resp.text(); - const blob = new Blob([content], { type: 'text/html' }); + const content = await resp.blob(); + const mimeType = EXT_MIME_TYPES[ext] || 'application/octet-stream'; + const blob = new Blob([content], { type: mimeType }); const formData = new FormData(); formData.append('data', blob); - const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.html`; + const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.${ext}`; const saveResp = await daFetch(satPath, { method: 'PUT', body: formData }); if (!saveResp.ok) return { error: `Failed to create override (${saveResp.status})` }; return { ok: true }; } -export async function deleteOverride(org, satellite, pagePath) { - const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.html`; +export async function deleteOverride(org, satellite, pagePath, ext = 'html') { + const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.${ext}`; const resp = await daFetch(satPath, { method: 'DELETE' }); if (!resp.ok) return { error: `Failed to delete override (${resp.status})` }; return { ok: true }; } -export async function getSatellitePageStatus(org, satellite, pagePath) { - const aemPath = pagePath.replace(/\.html$/, ''); +export async function getSatellitePageStatus(org, satellite, pagePath, ext = 'html') { + const aemPath = ext === 'html' ? pagePath : `${pagePath}.${ext}`; const url = `${AEM_ADMIN}/status/${org}/${satellite}/main${aemPath}`; const resp = await daFetch(url); if (!resp.ok) return { preview: false, live: false }; @@ -216,12 +395,12 @@ async function ensureMergeCopy() { return mergeCopyFn; } -export async function mergeFromBase(org, baseSite, satellite, pagePath) { +export async function mergeFromBase(org, baseSite, satellite, pagePath, ext = 'html') { try { const mergeCopy = await ensureMergeCopy(); const url = { - source: `/${org}/${baseSite}${pagePath}.html`, - destination: `/${org}/${satellite}${pagePath}.html`, + source: `/${org}/${baseSite}${pagePath}.${ext}`, + destination: `/${org}/${satellite}${pagePath}.${ext}`, }; const result = await mergeCopy(url, 'MSM Merge'); if (!result?.ok) return { error: 'Merge failed' }; @@ -250,7 +429,8 @@ export async function executeBulkAction({ const satEntries = Object.entries(satellites); const tasks = pages.flatMap((page) => { - const pagePath = page.path.replace(/\.html$/, ''); + const ext = getExtension(page.path) || 'html'; + const pagePath = stripExtension(page.path); const pageOverrides = overrides?.get(page.path) || []; const applicableSats = scope @@ -268,6 +448,10 @@ export async function executeBulkAction({ skipped.forEach((s) => onSkipped?.(page, s, scope)); } + applicableSats.forEach(([satSite]) => { + onPageStatus?.(`${page.path}:${satSite}`, 'queued'); + }); + return applicableSats.map(([satSite]) => async () => { const key = `${page.path}:${satSite}`; onPageStatus?.(key, 'pending'); @@ -276,28 +460,34 @@ export async function executeBulkAction({ let result; switch (action) { case 'preview': - result = await previewSatellite(org, satSite, pagePath); + result = await previewSatellite(org, satSite, pagePath, ext); break; case 'publish': - result = await publishSatellite(org, satSite, pagePath); + result = await publishSatellite(org, satSite, pagePath, ext); break; case 'break': - result = await createOverride(org, baseSite, satSite, pagePath); + case 'cancel-inheritance': + // 'cancel-inheritance' is the upward (satellite-side) framing of + // the same operation: materialize the inherited page locally, + // breaking the inheritance link. + result = await createOverride(org, baseSite, satSite, pagePath, ext); break; case 'sync': + case 'sync-from-base': result = syncMode === 'merge' - ? await mergeFromBase(org, baseSite, satSite, pagePath) - : await createOverride(org, baseSite, satSite, pagePath); + ? await mergeFromBase(org, baseSite, satSite, pagePath, ext) + : await createOverride(org, baseSite, satSite, pagePath, ext); break; - case 'reset': { - const pageStatus = await getSatellitePageStatus(org, satSite, pagePath); - result = await deleteOverride(org, satSite, pagePath); + case 'reset': + case 'resume-inheritance': { + const pageStatus = await getSatellitePageStatus(org, satSite, pagePath, ext); + result = await deleteOverride(org, satSite, pagePath, ext); if (!result?.error) { if (pageStatus.live) { - await previewSatellite(org, satSite, pagePath); - await publishSatellite(org, satSite, pagePath); + await previewSatellite(org, satSite, pagePath, ext); + await publishSatellite(org, satSite, pagePath, ext); } else if (pageStatus.preview) { - await previewSatellite(org, satSite, pagePath); + await previewSatellite(org, satSite, pagePath, ext); } } break; diff --git a/tools/apps/msm/helpers/column-browser.css b/tools/apps/msm/helpers/column-browser.css index cc48bcd..ef40f95 100644 --- a/tools/apps/msm/helpers/column-browser.css +++ b/tools/apps/msm/helpers/column-browser.css @@ -189,6 +189,43 @@ color: var(--s2-gray-500); } +/* ===== Inherited item (page or folder served via MSM fallback) ===== */ + +.item.inherited .item-label { + color: var(--s2-gray-600, #6e6e6e); +} + +.item.inherited .item-icon { + opacity: 0.7; +} + +.inherited-badge { + flex-shrink: 0; + width: 14px; + height: 14px; + margin-left: -2px; + color: var(--s2-gray-700, #505050); +} + +/* ===== Blocked item (selection mutex: different category from current) ===== */ + +.item.blocked { + opacity: 0.45; + cursor: not-allowed; +} + +.item.blocked .item-label, +.item.blocked .item-icon, +.item.blocked .inherited-badge { + pointer-events: none; +} + +.item.blocked input[type="checkbox"]:disabled { + cursor: not-allowed; + border-color: var(--s2-gray-400, #b1b1b1); + background: transparent; +} + /* ===== Override dot indicator ===== */ .override-dot { diff --git a/tools/apps/msm/helpers/column-browser.js b/tools/apps/msm/helpers/column-browser.js index b7183ff..f252eec 100644 --- a/tools/apps/msm/helpers/column-browser.js +++ b/tools/apps/msm/helpers/column-browser.js @@ -1,6 +1,6 @@ /* eslint-disable no-underscore-dangle, import/no-unresolved, no-console, class-methods-use-this */ import { LitElement, html, nothing } from 'da-lit'; -import { listFolder } from './api.js'; +import { listFolder, listFolderWithInheritance, isActionableItem } from './api.js'; const NX = 'https://da.live/nx'; let sl; @@ -19,6 +19,7 @@ const FOLDER_ICON = html``; const ARROW_RIGHT = html``; const BACK_ARROW = html``; +const INHERITED_BADGE = html``; class MsmColumnBrowser extends LitElement { static properties = { @@ -26,11 +27,14 @@ class MsmColumnBrowser extends LitElement { role: { type: String }, site: { type: String }, msmConfig: { attribute: false }, + hideInherited: { type: Boolean }, + deepLinkPath: { type: String }, _columns: { state: true }, _checked: { state: true }, _activeColumnIdx: { state: true }, _loadingColumn: { state: true }, - _focusedItemIdx: { state: true }, + _focusedItem: { state: true }, + _selectionCategory: { state: true }, }; connectedCallback() { @@ -40,8 +44,13 @@ class MsmColumnBrowser extends LitElement { this._checked = new Set(); this._activeColumnIdx = 0; this._loadingColumn = -1; - this._focusedItemIdx = -1; + this._focusedItem = null; + this._selectionCategory = null; this._folderCache = new Map(); + this._mergedFolderCache = new Map(); + // Monotonic counter used by `emitSelection` to detect and drop stale + // dispatches when the user check/unchecks faster than folder pages load. + this._emitSeq = 0; this._handleKeydown = this._onKeydown.bind(this); if (this.site) { @@ -51,54 +60,153 @@ class MsmColumnBrowser extends LitElement { } } + updated(changed) { + if (changed.has('hideInherited') && this.hideInherited) { + this._pruneHiddenInheritedChecks(); + } + } + + _setFocus(columnIdx, item) { + this._focusedItem = item ? { + columnIdx, + path: item.path, + site: item.site || '', + } : null; + } + + _getActiveFocusedIdx() { + const f = this._focusedItem; + if (!f || f.columnIdx !== this._activeColumnIdx) return -1; + const col = this._columns[this._activeColumnIdx]; + if (!col) return -1; + const items = this._visibleItems(col); + return items.findIndex((it) => ( + it.path === f.path && (it.site || '') === f.site + )); + } + + _pruneHiddenInheritedChecks() { + if (!this._checked.size) return; + const itemByKey = new Map(); + this._columns.forEach((col) => { + col.items.forEach((it) => { + itemByKey.set(`${it.site || ''}:${it.path}`, it); + }); + }); + + const next = new Set(this._checked); + let modified = false; + this._checked.forEach((key) => { + const it = itemByKey.get(key); + if (it?.inheritedFrom) { + next.delete(key); + modified = true; + } + }); + if (!modified) return; + + this._checked = next; + this._focusedItem = null; + this._refreshSelectionCategory(); + this.emitSelection(this.getCurrentSite()); + } + + _itemCategory(item) { + if (item.inheritedFrom) return 'inherited'; + if (item.hasLocalOverride) return 'overridden'; + return 'local'; + } + + _isCheckBlocked(item) { + if (!this._selectionCategory) return false; + if (this.isItemChecked(item)) return false; + return this._itemCategory(item) !== this._selectionCategory; + } + + invalidateMergedCache() { + this._mergedFolderCache = new Map(); + } + + _siteHasInheritance(site) { + if (!this.msmConfig || !site) return false; + return (this.msmConfig.rows || []) + .some((row) => row.satellite === site); + } + + async _loadFolderItems(site, path) { + if (this._siteHasInheritance(site)) { + const cacheKey = `${site}::${path}`; + if (this._mergedFolderCache.has(cacheKey)) { + return this._mergedFolderCache.get(cacheKey); + } + const items = await listFolderWithInheritance(this.org, site, path, this.msmConfig); + this._mergedFolderCache.set(cacheKey, items); + return items; + } + return listFolder(this.org, site, path); + } + disconnectedCallback() { super.disconnectedCallback(); } _onKeydown(e) { const col = this._columns[this._activeColumnIdx]; - if (!col?.items.length) return; + if (!col) return; + const items = this._visibleItems(col); + if (!items.length) return; + const curIdx = this._getActiveFocusedIdx(); switch (e.key) { - case 'ArrowDown': + case 'ArrowDown': { e.preventDefault(); - this._focusedItemIdx = Math.min( - this._focusedItemIdx + 1, - col.items.length - 1, - ); + const nextIdx = Math.min(curIdx + 1, items.length - 1); + this._setFocus(this._activeColumnIdx, items[nextIdx]); this.scrollFocusedIntoView(); break; - case 'ArrowUp': + } + case 'ArrowUp': { e.preventDefault(); - this._focusedItemIdx = Math.max(this._focusedItemIdx - 1, 0); + const prevIdx = Math.max(curIdx - 1, 0); + this._setFocus(this._activeColumnIdx, items[prevIdx]); this.scrollFocusedIntoView(); break; + } case 'ArrowRight': case 'Enter': { e.preventDefault(); - const item = col.items[this._focusedItemIdx]; + const item = items[curIdx]; if (item && (item.isFolder || item.isSite)) { - this.navigateToFolder(this._activeColumnIdx, item); - this._focusedItemIdx = 0; + const fromColumn = this._activeColumnIdx; + this.navigateToFolder(fromColumn, item).then(() => { + const newCol = this._columns[fromColumn + 1]; + const visible = newCol ? this._visibleItems(newCol) : []; + if (visible.length) { + this._setFocus(fromColumn + 1, visible[0]); + this.scrollFocusedIntoView(); + } + }); } break; } - case 'ArrowLeft': + case 'ArrowLeft': { e.preventDefault(); if (this._activeColumnIdx > 0) { this._activeColumnIdx -= 1; - this._focusedItemIdx = 0; + const prevCol = this._columns[this._activeColumnIdx]; + const visible = prevCol ? this._visibleItems(prevCol) : []; + this._setFocus(this._activeColumnIdx, visible[0] || null); } break; - case ' ': + } + case ' ': { e.preventDefault(); - if (this._focusedItemIdx >= 0) { - const item = col.items[this._focusedItemIdx]; - if (item && this.showCheckbox(item)) { - this.toggleCheck(item); - } + const item = items[curIdx]; + if (item && this.showCheckbox(item)) { + this.toggleCheck(item, this._activeColumnIdx); } break; + } default: break; } @@ -106,12 +214,16 @@ class MsmColumnBrowser extends LitElement { scrollFocusedIntoView() { this.updateComplete.then(() => { - const items = this.shadowRoot.querySelectorAll( - `.column:nth-child(${this._activeColumnIdx + 1}) .item`, + const f = this._focusedItem; + if (!f) return; + const els = this.shadowRoot.querySelectorAll( + `.column:nth-child(${f.columnIdx + 1}) .item`, ); - if (items[this._focusedItemIdx]) { - items[this._focusedItemIdx].scrollIntoView({ block: 'nearest' }); - } + const target = Array.from(els).find((el) => ( + el.dataset.path === f.path + && (el.dataset.site || '') === f.site + )); + if (target) target.scrollIntoView({ block: 'nearest' }); }); } @@ -125,16 +237,45 @@ class MsmColumnBrowser extends LitElement { }); } - toggleCheck(item) { + toggleCheck(item, colIdx) { + if (this._isCheckBlocked(item)) return; + // The user is acting on column `colIdx`; any deeper columns belong to a + // previously-opened folder that's no longer the current focus. Collapse + // them so the browser reflects the user's new context. + if (Number.isInteger(colIdx)) this._collapseColumnsAfter(colIdx); const next = new Set(this._checked); const key = `${item.site || ''}:${item.path}`; - if (next.has(key)) next.delete(key); + const willUncheck = next.has(key); + if (willUncheck) next.delete(key); else next.add(key); this._checked = next; + if (willUncheck) this._clearFocusIfMatches(item); + this._refreshSelectionCategory(); const site = item.site || this.getCurrentSite(); this.emitSelection(site); } + // Drops every column to the right of `colIdx` and prunes any checks that + // lived in those discarded columns. No-op when `colIdx` is already the + // rightmost column. + _collapseColumnsAfter(colIdx) { + if (colIdx >= this._columns.length - 1) return; + this._columns = this._columns.slice(0, colIdx + 1); + if (this._activeColumnIdx > colIdx) this._activeColumnIdx = colIdx; + if (this._focusedItem && this._focusedItem.columnIdx > colIdx) { + this._focusedItem = null; + } + this.clearChecksAfterColumn(colIdx); + } + + _clearFocusIfMatches(item) { + const f = this._focusedItem; + if (!f) return; + if (f.path === item.path && f.site === (item.site || '')) { + this._focusedItem = null; + } + } + initSitesColumn() { if (!this.msmConfig?.baseSites?.length) return; const items = this.msmConfig.baseSites.map((bs) => ({ @@ -154,7 +295,7 @@ class MsmColumnBrowser extends LitElement { }]; try { - const items = await listFolder(this.org, this.site, '/'); + const items = await this._loadFolderItems(this.site, '/'); this._columns = [{ header: this.site, items: items.map((i) => ({ ...i, site: this.site })), @@ -165,6 +306,76 @@ class MsmColumnBrowser extends LitElement { this._columns = [{ header: this.site, items: [], selectedPath: null }]; } this._loadingColumn = -1; + + if (this.deepLinkPath && !this._deepLinkConsumed) { + this._deepLinkConsumed = true; + await this._navigateToPath(this.deepLinkPath); + } + } + + async _navigateToPath(path) { + const requested = path; + const normalized = path.startsWith('/') ? path : `/${path}`; + const parts = normalized.split('/').filter(Boolean); + + this._suppressEmit = true; + let colIdx = 0; + let cumPath = ''; + let lastResolved = ''; + let resolvedFully = parts.length === 0; + + try { + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < parts.length; i += 1) { + cumPath += `/${parts[i]}`; + const stepPath = cumPath; + const col = this._columns[colIdx]; + if (!col) break; + + const isLast = i === parts.length - 1; + let item = col.items.find((it) => it.path === stepPath); + if (!item && isLast && !/\.[a-z0-9]+$/i.test(parts[i])) { + // Fall back to `.html` when the last segment lacks an extension. + const htmlPath = `${stepPath}.html`; + item = col.items.find((it) => it.path === htmlPath); + } + if (!item) break; + lastResolved = item.path; + + if (isLast) { + if (item.isFolder || item.isSite) { + await this.navigateToFolder(colIdx, item); + } else if (this.showCheckbox(item)) { + this.toggleCheck(item, colIdx); + this._setFocus(colIdx, item); + } + resolvedFully = true; + } else if (item.isFolder || item.isSite) { + await this.navigateToFolder(colIdx, item); + colIdx += 1; + } else { + // Path expects a folder here but got a page; stop the walk. + break; + } + } + /* eslint-enable no-await-in-loop */ + } finally { + this._suppressEmit = false; + } + + await this.emitSelection(this.getCurrentSite()); + + if (!resolvedFully) { + this.dispatchEvent(new CustomEvent('deep-link-warning', { + detail: { requestedPath: requested, lastResolvedPath: lastResolved }, + bubbles: true, + composed: true, + })); + } + this.dispatchEvent(new CustomEvent('deep-link-consumed', { + bubbles: true, + composed: true, + })); } async navigateToFolder(colIdx, item) { @@ -180,9 +391,9 @@ class MsmColumnBrowser extends LitElement { try { let items; if (item.isSite) { - items = await listFolder(this.org, site, '/'); + items = await this._loadFolderItems(site, '/'); } else { - items = await listFolder(this.org, site, item.path); + items = await this._loadFolderItems(site, item.path); } items = items.map((i) => ({ ...i, site })); @@ -245,25 +456,50 @@ class MsmColumnBrowser extends LitElement { if (item.isFolder || item.isSite) { this.navigateToFolder(colIdx, item); } else { - this.toggleCheck(item); + this.toggleCheck(item, colIdx); } } - handleCheckChange(item, e) { + handleCheckChange(item, e, colIdx) { e.stopPropagation(); + if (this._isCheckBlocked(item)) { + e.target.checked = false; + return; + } + if (Number.isInteger(colIdx)) this._collapseColumnsAfter(colIdx); const next = new Set(this._checked); const key = `${item.site || ''}:${item.path}`; if (e.target.checked) { next.add(key); } else { next.delete(key); + this._clearFocusIfMatches(item); } this._checked = next; + this._refreshSelectionCategory(); const site = item.site || this.getCurrentSite(); this.emitSelection(site); } + _refreshSelectionCategory() { + if (this._checked.size === 0) { + this._selectionCategory = null; + return; + } + const firstKey = this._checked.values().next().value; + let category = null; + this._columns.some((col) => col.items.some((item) => { + const key = `${item.site || ''}:${item.path}`; + if (key === firstKey) { + category = this._itemCategory(item); + return true; + } + return false; + })); + this._selectionCategory = category; + } + clearChecksAfterColumn(colIdx) { const validPaths = new Set( this._columns.slice(0, colIdx + 1).flatMap( @@ -271,16 +507,25 @@ class MsmColumnBrowser extends LitElement { ), ); this._checked = new Set([...this._checked].filter((key) => validPaths.has(key))); + this._refreshSelectionCategory(); } async emitSelection(site) { + if (this._suppressEmit) return; + // Snapshot `_checked` synchronously so a later check/uncheck that fires + // its own `emitSelection` can't mutate what this call considers selected. + // Combined with the seq check below, this guarantees an in-flight stale + // dispatch can never re-introduce items the user has just unchecked. + this._emitSeq += 1; + const mySeq = this._emitSeq; + const checkedSnapshot = new Set(this._checked); const checkedPages = []; const checkedFolders = []; this._columns.forEach((col) => { col.items.forEach((item) => { const key = `${item.site || site || ''}:${item.path}`; - if (!this._checked.has(key)) return; + if (!checkedSnapshot.has(key)) return; if (item.isFolder && !item.isSite) { checkedFolders.push(item); } else if (!item.isSite) { @@ -295,11 +540,11 @@ class MsmColumnBrowser extends LitElement { const cacheKey = `${this.org}/${folderSite}${folder.path}`; if (!this._folderCache.has(cacheKey)) { try { - const items = await listFolder(this.org, folderSite, folder.path); - this._folderCache.set( - cacheKey, - items.filter((i) => i.ext === 'html').map((i) => ({ ...i, site: folderSite })), - ); + const items = await this._loadFolderItems(folderSite, folder.path); + const files = items + .filter((i) => !i.isFolder && !i.isSite) + .map((i) => ({ ...i, site: folderSite })); + this._folderCache.set(cacheKey, files); } catch (e) { console.error('Failed to resolve folder pages:', e); this._folderCache.set(cacheKey, []); @@ -309,6 +554,11 @@ class MsmColumnBrowser extends LitElement { }), ); + // A newer `emitSelection` has started (user clicked again while we were + // awaiting folder loads); drop this stale result instead of overriding + // the newer dispatch with files for a no-longer-checked folder. + if (mySeq !== this._emitSeq) return; + const allItems = [...checkedPages, ...folderPages.flat()]; const seen = new Set(); const selectedItems = allItems.filter((item) => { @@ -339,17 +589,31 @@ class MsmColumnBrowser extends LitElement { } showCheckbox(item) { - return !item.isSite && (item.ext === 'html' || item.isFolder); + return !item.isSite && (isActionableItem(item) || item.isFolder); } - renderItem(colIdx, item, itemIdx) { + renderItem(colIdx, item) { const isSelected = this._columns[colIdx]?.selectedPath === item.path; - const isFocused = colIdx === this._activeColumnIdx - && itemIdx === this._focusedItemIdx; + const f = this._focusedItem; + const isFocused = !!f + && f.columnIdx === colIdx + && f.path === item.path + && f.site === (item.site || ''); + const isInherited = !!item.inheritedFrom; + const blocked = this._isCheckBlocked(item); + const tooltipParts = []; + if (isInherited) tooltipParts.push(`Inherited from ${item.inheritedFrom}`); + if (blocked) { + tooltipParts.push(`Cannot mix with ${this._selectionCategory} pages. Clear the selection to switch categories.`); + } + const title = tooltipParts.join(' \u2022 ') || undefined; return html`
      this.handleItemClick(colIdx, item, e)} + title=${title || nothing} role="option" aria-selected=${isSelected} > @@ -357,20 +621,28 @@ class MsmColumnBrowser extends LitElement { this.handleCheckChange(item, e)} + ?disabled=${blocked} + @change=${(e) => this.handleCheckChange(item, e, colIdx)} @click=${(e) => e.stopPropagation()} /> ` : nothing} ${item.isFolder || item.isSite ? FOLDER_ICON : PAGE_ICON} + ${isInherited ? INHERITED_BADGE : nothing} ${item.name} ${item.isFolder || item.isSite ? ARROW_RIGHT : nothing}
      `; } + _visibleItems(col) { + if (!this.hideInherited) return col.items; + return col.items.filter((i) => !i.inheritedFrom); + } + renderColumn(col, colIdx) { const isActive = colIdx === this._activeColumnIdx; const isLoading = this._loadingColumn === colIdx; + const items = this._visibleItems(col); return html`
      @@ -388,10 +660,10 @@ class MsmColumnBrowser extends LitElement {
      Loading\u2026
      ` : nothing} - ${!isLoading && col.items.length === 0 ? html` + ${!isLoading && items.length === 0 ? html`
      Empty folder
      ` : nothing} - ${!isLoading ? col.items.map( + ${!isLoading ? items.map( (item, idx) => this.renderItem(colIdx, item, idx), ) : nothing}
    diff --git a/tools/apps/msm/msm.css b/tools/apps/msm/msm.css index 90a2e41..60bf027 100644 --- a/tools/apps/msm/msm.css +++ b/tools/apps/msm/msm.css @@ -31,9 +31,16 @@ flex: 1; } +.msm-toolbar-role-badges { + display: flex; + align-items: center; + gap: 12px; + margin-top: 12px; + flex-wrap: wrap; +} + .role-badge { display: inline-block; - margin-top: 12px; font-size: var(--s2-body-xxs-size, 11px); font-weight: 600; text-transform: uppercase; @@ -44,6 +51,75 @@ color: var(--s2-blue-900); } +.role-badge.dual { + background: var(--s2-purple-100, #f5e8ff); + color: var(--s2-purple-900, #4b1d76); +} + +.hide-inherited-toggle { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 14px; + color: var(--s2-gray-900, #292929); + cursor: pointer; + user-select: none; + width: max-content; +} + +.hide-inherited-toggle input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + position: relative; + margin: 0; + width: 26px; + height: 14px; + border-radius: 7px; + background: var(--s2-gray-300, #d4d4d4); + cursor: pointer; + flex-shrink: 0; + transition: background-color 0.15s ease-in-out; +} + +.hide-inherited-toggle input[type="checkbox"]::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 10px; + height: 10px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgb(0 0 0 / 20%); + transition: transform 0.15s ease-in-out; +} + +.hide-inherited-toggle input[type="checkbox"]:checked { + background: var(--s2-blue-700, #393dba); +} + +.hide-inherited-toggle input[type="checkbox"]:checked::after { + transform: translateX(12px); +} + +.hide-inherited-toggle input[type="checkbox"]:hover:not(:disabled) { + background: var(--s2-gray-400, #b1b1b1); +} + +.hide-inherited-toggle input[type="checkbox"]:checked:hover:not(:disabled) { + background: var(--s2-blue-800, #2c3093); +} + +.hide-inherited-toggle input[type="checkbox"]:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; +} + +.hide-inherited-toggle input[type="checkbox"]:disabled { + opacity: 0.4; + cursor: default; +} + /* ===== Loading / Empty ===== */ .msm-loading, @@ -101,6 +177,36 @@ background: var(--s2-orange-100); } +.deep-link-warning, .site-warning { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.deep-link-warning .nx-alert-dismiss, .site-warning .nx-alert-dismiss { + appearance: none; + background: transparent; + border: none; + color: var(--s2-gray-700); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + flex-shrink: 0; +} + +.deep-link-warning .nx-alert-dismiss:hover { + background: rgb(0 0 0 / 5%); + color: var(--s2-gray-900); +} + +.deep-link-warning .nx-alert-dismiss:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; +} + /* ===== Layout ===== */ .msm-body { diff --git a/tools/apps/msm/msm.js b/tools/apps/msm/msm.js index 40f85bf..317965e 100644 --- a/tools/apps/msm/msm.js +++ b/tools/apps/msm/msm.js @@ -1,7 +1,12 @@ /* eslint-disable no-underscore-dangle, import/no-unresolved, no-console, class-methods-use-this */ import DA_SDK from 'https://da.live/nx/utils/sdk.js'; import { LitElement, html, nothing } from 'da-lit'; -import { fetchMsmConfig, checkPageOverrides } from './helpers/api.js'; +import { + fetchMsmConfig, + checkPageOverrides, + isActionableItem, + getSiteRoles, +} from './helpers/api.js'; import 'https://da.live/nx/public/sl/components.js'; import './helpers/column-browser.js'; import './helpers/action-panel.js'; @@ -21,15 +26,45 @@ try { console.warn('Failed to load styles:', e); } +const HIDE_INHERITED_KEY = 'da-msm-hide-inherited'; + +function loadHideInheritedPref() { + try { + return localStorage.getItem(HIDE_INHERITED_KEY) === 'true'; + } catch { + return false; + } +} + +function saveHideInheritedPref(value) { + try { + localStorage.setItem(HIDE_INHERITED_KEY, String(value)); + } catch { + /* localStorage may be unavailable in private mode; ignore */ + } +} + +function parseDeepLink() { + const params = new URLSearchParams(window.location.search); + const org = (params.get('org') || '').trim(); + if (!org) return null; + return { + org, + site: (params.get('site') || '').trim(), + path: (params.get('path') || '').trim(), + }; +} + class MsmApp extends LitElement { static properties = { context: { attribute: false }, token: { attribute: false }, + deepLink: { attribute: false }, _state: { state: true }, _org: { state: true }, _site: { state: true }, _role: { state: true }, - _baseSite: { state: true }, + _parentBase: { state: true }, _msmConfig: { state: true }, _selectedItems: { state: true }, _currentPath: { state: true }, @@ -37,6 +72,11 @@ class MsmApp extends LitElement { _pageOverrides: { state: true }, _initError: { state: true }, _siteWarning: { state: true }, + _parentChain: { state: true }, + _hasDescendants: { state: true }, + _hideInherited: { state: true }, + _deepLinkPath: { state: true }, + _deepLinkWarning: { state: true }, }; connectedCallback() { @@ -49,6 +89,51 @@ class MsmApp extends LitElement { this._initError = ''; this._role = 'base'; this._state = 'init'; + this._parentBase = ''; + this._parentChain = []; + this._hasDescendants = false; + this._hideInherited = loadHideInheritedPref(); + this._deepLinkPath = ''; + this._deepLinkWarning = ''; + + // Auto-load when a deep-link was supplied via URL query params. + if (this.deepLink?.org) { + this._org = this.deepLink.org; + this._site = this.deepLink.site || ''; + this._deepLinkPath = this.deepLink.path || ''; + this._state = 'loading'; + this.loadConfig(this.deepLink.org); + } + } + + handleDeepLinkConsumed() { + this._deepLinkPath = ''; + } + + handleDeepLinkWarning(e) { + const { requestedPath, lastResolvedPath } = e.detail || {}; + const tail = lastResolvedPath + ? ` (navigated as far as ${lastResolvedPath})` + : ''; + this._deepLinkWarning = `Could not resolve "${requestedPath}"${tail}.`; + } + + dismissSiteWarning() { + this._siteWarning = ''; + } + + dismissDeepLinkWarning() { + this._deepLinkWarning = ''; + } + + onHideInheritedToggle(checked) { + this._hideInherited = checked; + saveHideInheritedPref(checked); + } + + handleActionComplete() { + const cb = this.shadowRoot.querySelector('msm-column-browser'); + cb?.invalidateMergedCache(); } handleOrgSubmit(e) { @@ -71,37 +156,56 @@ class MsmApp extends LitElement { classifySite(config) { this._siteWarning = ''; + this._parentChain = []; + this._parentBase = ''; + this._hasDescendants = false; + this._satellites = {}; + this._browseSite = this._site; if (!this._site) { this._role = 'base'; - this._baseSite = ''; return; } - const isSatellite = config.baseSites.find( - (bs) => Object.keys(bs.satellites).includes(this._site), - ); + const roles = getSiteRoles(config, this._site); + const isBase = !!roles.asBase; + const isSatellite = !!roles.asSatellite; + if (isSatellite) { - this._role = 'satellite'; - this._baseSite = isSatellite.site; - this._satellites = { - [this._site]: isSatellite.satellites[this._site], - }; + this._parentBase = roles.asSatellite.base; + this._parentChain = roles.asSatellite.chain || []; + } + + if (isBase && isSatellite) { + // Middle-tier site: has children AND has a parent + this._role = 'dual'; + this._satellites = roles.asBase.satellites; + this._hasDescendants = Object.values(roles.asBase.satellites) + .some((s) => s.descendantCount > 0); return; } - const isBase = config.baseSites.find((bs) => bs.site === this._site); if (isBase) { this._role = 'base'; - this._baseSite = this._site; - this._satellites = isBase.satellites; + this._satellites = roles.asBase.satellites; + this._hasDescendants = Object.values(roles.asBase.satellites) + .some((s) => s.descendantCount > 0); + return; + } + + if (isSatellite) { + this._role = 'satellite'; + const leafEntry = config.baseSites + .find((bs) => Object.keys(bs.satellites).includes(this._site)) + ?.satellites[this._site]; + this._satellites = { [this._site]: leafEntry || { label: this._site } }; return; } const fallback = config.baseSites[0]; this._role = 'base'; - this._baseSite = fallback.site; this._satellites = fallback.satellites; + this._browseSite = fallback.site; this._siteWarning = `"${this._site}" is not a recognized base or satellite site. Showing "${fallback.site}" instead.`; } @@ -128,21 +232,44 @@ class MsmApp extends LitElement { this._currentPath = currentPath; this._currentSite = site; + if (this._role === 'satellite' || this._role === 'dual') { + const selfSite = this._role === 'satellite' ? this._site : site; + this._pageOverrides = this._buildSelfOverrides(selectedItems, selfSite); + } else { + this._pageOverrides = new Map(); + } + if (this._role === 'satellite') return; if (selectedItems.length > 0 && this._msmConfig) { - const baseSite = this._msmConfig.baseSites.find((s) => s.site === site); - if (baseSite) { - this._satellites = baseSite.satellites; + const roles = getSiteRoles(this._msmConfig, site); + if (roles.asBase) { + this._satellites = roles.asBase.satellites; + this._parentChain = roles.asSatellite?.chain || []; + this._parentBase = roles.asSatellite?.base || ''; + this._hasDescendants = Object.values(roles.asBase.satellites) + .some((s) => s.descendantCount > 0); this.loadOverrides(selectedItems); } - } else { - this._pageOverrides = new Map(); } } + _buildSelfOverrides(selectedItems, selfSite) { + const map = new Map(); + selectedItems.filter(isActionableItem).forEach((page) => { + map.set(page.path, [{ + site: selfSite, + label: selfSite, + hasOverride: !page.inheritedFrom, + inheritedFrom: page.inheritedFrom || null, + sourceSite: page.sourceSite || null, + }]); + }); + return map; + } + async loadOverrides(items) { - const pages = items.filter((i) => i.ext === 'html'); + const pages = items.filter((i) => isActionableItem(i)); if (!pages.length) return; const org = this._org; @@ -150,16 +277,27 @@ class MsmApp extends LitElement { const overrides = new Map(); await Promise.all(pages.map(async (page) => { - const pagePath = page.path.replace('.html', ''); - const results = await checkPageOverrides(org, sats, pagePath); + const ext = page.ext || 'html'; + const pagePath = page.path.replace(/\.[^/.]+$/, ''); + const results = await checkPageOverrides(org, sats, pagePath, ext); overrides.set(page.path, results); })); - this._pageOverrides = new Map(overrides); + const merged = new Map(); + const allPaths = new Set([ + ...Array.from(this._pageOverrides?.keys?.() || []), + ...overrides.keys(), + ]); + allPaths.forEach((p) => { + const selfEntries = this._pageOverrides?.get(p) || []; + const childEntries = overrides.get(p) || []; + merged.set(p, [...selfEntries, ...childEntries]); + }); + this._pageOverrides = merged; } get _selectedPages() { - return this._selectedItems.filter((i) => i.ext === 'html'); + return this._selectedItems.filter((i) => isActionableItem(i)); } get _isSinglePage() { @@ -172,6 +310,7 @@ class MsmApp extends LitElement { } renderToolbar() { + const canShowInheritedToggle = this._role === 'satellite' || this._role === 'dual'; return html`

    Multi-Site Management

    @@ -188,7 +327,21 @@ class MsmApp extends LitElement { > Load - ${this._role === 'satellite' ? html`Satellite` : nothing} +
    + ${this._role === 'satellite' ? html`Satellite` : nothing} + ${this._role === 'dual' ? html`Middle-tier` : nothing} + ${canShowInheritedToggle ? html` + + ` : nothing} +
    `; } @@ -214,25 +367,48 @@ class MsmApp extends LitElement { } return html` - ${this._siteWarning ? html`

    ${this._siteWarning}

    ` : nothing} + ${this._siteWarning ? html` +
    +

    ${this._siteWarning}

    + +
    + ` : nothing} + ${this._deepLinkWarning ? html` + + ` : nothing}
    ${this._selectedPages.length > 0 ? html` ` : nothing}
    @@ -250,9 +426,11 @@ class MsmApp extends LitElement { customElements.define('msm-app', MsmApp); (async function init() { + const deepLink = parseDeepLink(); const { context, token } = await DA_SDK; const cmp = document.createElement('msm-app'); cmp.context = context; cmp.token = token; + if (deepLink) cmp.deepLink = deepLink; document.body.append(cmp); }()); diff --git a/tools/plugins/msm/README.md b/tools/plugins/msm/README.md new file mode 100644 index 0000000..1ef128a --- /dev/null +++ b/tools/plugins/msm/README.md @@ -0,0 +1,125 @@ +# Multi-site Manager (MSM) Plugin + +A DA Prepare-menu plugin for managing multi-site inheritance from the page editor. +Lets authors roll out, sync, override, and resume inheritance between a base site +and its satellite sites without leaving DA. + +This plugin is a standalone fork of the OOTB `Multi-site Manager` action that +ships in [`da-live`](https://github.com/adobe/da-live/tree/main/blocks/edit/da-prepare/actions/msm). +The OOTB version is still available by default; sites that want to opt in to +the plugin (e.g. to pin a specific version, or to use the plugin URL from any +site config without copying code) can configure their `prepare` sheet as +described below. + +## How it works + +1. Authors open a page in DA, click the Prepare menu, and pick **Multi-site Manager**. +2. The plugin reads the org's `msm` config sheet to determine the page's role: + - **As a base** — lists satellites that inherit from this site. + - **As a satellite** — shows the inheritance chain up to the base. + - **Dual role** — both, with a "Sync from parent" switch to flip direction. +3. The author picks an action (roll out to preview/live, cancel inheritance, + sync from base, resume inheritance, etc.) and the plugin executes it via + the DA Admin and AEM Admin APIs. + +The plugin uses [`DA_SDK`](https://da.live/nx/utils/sdk.js) so all admin calls +are authenticated against the host page's signed-in user — no separate auth +is required. + +## Configuration + +### 1. `.da/config.json` — `msm` sheet + +The plugin reads the org-level `msm` sheet to discover the base/satellite +relationships. Each row describes one site: + +| base | satellite | title | +| ------ | ----------- | --------------- | +| mccs | | MCCS Global | +| mccs | san-diego | San Diego | +| mccs | pendleton | Camp Pendleton | + +- A row with `base` set and `satellite` empty defines the **base label** for + that site (used in the breadcrumb). +- A row with both `base` and `satellite` defines an inheritance edge: edits + to the base flow to the satellite unless the satellite has a local override. +- Multi-level inheritance works: a `satellite` row can also appear as a + `base` row pointing to deeper satellites. + +### 2. `.da/config.json` — `prepare` sheet + +To make the plugin appear in the Prepare menu, add a row to the `prepare` +sheet at either the org or site level. Either point at this repo's hosted +plugin (no code copy needed) or use a relative path if you've vendored the +plugin into your own repo: + +**Option A: use the hosted plugin (recommended)** + +| title | path | icon | experience | +| ------------------ | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------- | +| Multi-site Manager | `https://main--da-blog-tools--aemsites.aem.live/tools/plugins/msm/msm.html` | `https://da.live/blocks/edit/img/S2_Icon_GlobeGrid_20_N.svg#S2_Icon_GlobeGrid` | dialog | + +**Option B: vendored copy in your repo** + +| title | path | icon | experience | +| ------------------ | --------------------------------- | --------------------------------------------------------------------- | ---------- | +| Multi-site Manager | `/tools/plugins/msm/msm.html` | `https://da.live/blocks/edit/img/S2_Icon_GlobeGrid_20_N.svg#S2_Icon_GlobeGrid` | dialog | + +> The DA Prepare menu merges items by `title`, so using `Multi-site Manager` +> as the title overrides the OOTB version when this row is present. + +## Behavior matrix + +| Page role | Direction available | Actions | +| ---------------- | ----------------------- | ------------------------------------------------------------------------- | +| Base only | Downward (children) | Roll out to preview · Roll out to live · Cancel inheritance · Sync · Resume inheritance | +| Satellite only | Upward (parent) | Sync from base · Resume inheritance | +| Both | Toggleable via switch | Both sets, scoped by the switch position | + +### Sync modes + +When syncing content from a base to a satellite (or vice versa), two modes are +available: + +- **Merge** — runs a 3-way merge that preserves local edits in the satellite + while pulling in changes from the base. Backed by the + `mergeCopy` function from [`nx/blocks/loc/project`](https://da.live/nx/blocks/loc/project/index.js), + loaded dynamically at runtime. +- **Override** — replaces the satellite's content with the base's content. + Local edits are lost. + +### Cascade to nested sites + +For recursive actions (Roll out to preview / live) on sites that have nested +descendants, an extra checkbox appears in the footer: +**"Cascade to nested sites (+N more)"**. When checked, the action runs against +the entire subtree below each selected satellite, not just the direct child. + +## Relationship to the OOTB version + +This plugin is a **fork-copy** of `blocks/edit/da-prepare/actions/msm/` in +da-live. The component itself (``) and its CSS are the same; only +the dependency wiring differs: + +| Concern | OOTB (da-live) | Plugin (this repo) | +| ------------------ | ----------------------------------------------- | ------------------------------------------------------------------ | +| Lit | `da-lit` resolved internally | `da-lit` resolved via importmap → `/tools/deps/lit/dist/index.js` | +| `daFetch` | `blocks/shared/utils.js` | `DA_SDK.actions.daFetch`, plumbed in via `setSdkFetch` | +| `DA_ORIGIN` | `blocks/shared/constants.js` | `https://da.live/nx/public/utils/constants.js` | +| NX URL | `getNx()` (versioned/branch-aware) | Hardcoded `https://da.live/nx` | +| `mergeCopy` | Dynamic import via `getNx()` | Dynamic import via the hardcoded NX URL | +| UI primitives | `se-*` components from `${nx}/public/se/components.js` | `sl-*` components from `${NX}/public/sl/components.js`, styled via nexter + `sl/styles.css` + `buttons.css` (loaded with `loadStyle` / `getStyle`) | +| Icons | Relative paths `/blocks/edit/img/...` | Absolute URLs `https://da.live/blocks/edit/img/...` | +| Edit-link origin | `window.location.origin` (always da.live) | Derived from `document.referrer`; falls back to `https://da.live` | + +## Files + +``` +tools/plugins/msm/ +├── README.md (this file) +├── msm.html (iframe entry; loads da-lit via importmap and msm.js) +├── msm.js ( Lit component + self-init from DA_SDK) +├── msm.css (component styles, adapted to fill the 400x400 iframe) +├── config.js (org/site msm-config resolution, override checks) +└── utils.js (preview/publish/override/merge AEM+DA admin calls) +``` diff --git a/tools/plugins/msm/config.js b/tools/plugins/msm/config.js new file mode 100644 index 0000000..cc99990 --- /dev/null +++ b/tools/plugins/msm/config.js @@ -0,0 +1,174 @@ +/* eslint-disable import/no-unresolved */ +import { DA_ORIGIN } from 'https://da.live/nx/public/utils/constants.js'; + +let sdkFetch; +export function setSdkFetch(fn) { sdkFetch = fn; } +function daFetch(url, opts) { + if (!sdkFetch) throw new Error('MSM plugin: SDK daFetch not initialised'); + return sdkFetch(url, opts); +} + +const configCache = {}; +const orgConfigPromises = {}; + +function fetchOrgConfig(org) { + if (!org) return Promise.resolve(null); + orgConfigPromises[org] ??= (async () => { + const url = `${DA_ORIGIN}/config/${org}/`; + const resp = await daFetch(url); + if (!resp.ok) return null; + const json = await resp.json(); + return json; + })(); + return orgConfigPromises[org]; +} + +async function fetchOrgMsmRows(org) { + const orgConfig = await fetchOrgConfig(org); + const rows = orgConfig?.msm?.data || []; + return rows; +} + +function getDirectChildren(rows, site) { + return rows + .filter((row) => row.base === site && row.satellite) + .map((row) => ({ site: row.satellite, label: row.title || row.satellite })); +} + +function getParentRow(rows, site) { + return rows.find((row) => row.satellite === site); +} + +function getBaseLabel(rows, site) { + const labelRow = rows.find((row) => row.base === site && !row.satellite); + return labelRow?.title; +} + +function walkSubtree(rows, rootSite, visited = new Set()) { + if (visited.has(rootSite)) return []; + visited.add(rootSite); + const children = getDirectChildren(rows, rootSite); + return children.flatMap((child) => [ + child, + ...walkSubtree(rows, child.site, visited), + ]); +} + +function walkChain(rows, site, visited = new Set()) { + const chain = []; + let current = site; + while (current && !visited.has(current)) { + visited.add(current); + const parentRow = getParentRow(rows, current); + if (!parentRow) break; + const baseLabel = getBaseLabel(rows, parentRow.base) || parentRow.base; + chain.unshift({ site: parentRow.base, label: baseLabel }); + current = parentRow.base; + } + return chain; +} + +function resolveConfig(rows, site) { + if (!rows.length || rows[0].base === undefined) return null; + + const directChildren = getDirectChildren(rows, site); + const parentRow = getParentRow(rows, site); + + if (!directChildren.length && !parentRow) return null; + + const result = {}; + + if (directChildren.length) { + const satellites = directChildren.reduce((acc, child) => { + const subtree = walkSubtree(rows, child.site); + acc[child.site] = { + label: child.label, + descendantCount: subtree.length, + }; + return acc; + }, {}); + result.asBase = { + baseLabel: getBaseLabel(rows, site), + satellites, + }; + } + + if (parentRow) { + const chain = walkChain(rows, site); + result.asSatellite = { + base: parentRow.base, + baseLabel: getBaseLabel(rows, parentRow.base) || parentRow.base, + chain, + }; + } + + return result; +} + +async function fetchSiteConfig(org, site) { + const key = `${org}/${site}`; + if (configCache[key]) return configCache[key]; + + const rows = await fetchOrgMsmRows(org); + if (!rows.length) { + return null; + } + + const config = resolveConfig(rows, site); + if (!config) { + return null; + } + + configCache[key] = { config, rows }; + return configCache[key]; +} + +export async function getSiteConfig(org, site) { + const entry = await fetchSiteConfig(org, site); + return entry?.config || null; +} + +export async function getSubtreeSatellites(org, baseSite) { + const entry = await fetchSiteConfig(org, baseSite); + if (!entry) return []; + return walkSubtree(entry.rows, baseSite); +} + +export async function getSatellites(org, baseSite) { + const config = await getSiteConfig(org, baseSite); + return config?.asBase?.satellites || {}; +} + +export async function getBaseSite(org, satellite) { + const config = await getSiteConfig(org, satellite); + return config?.asSatellite?.base || null; +} + +export async function isPageLocal(org, site, pagePath) { + const resp = await daFetch( + `${DA_ORIGIN}/source/${org}/${site}${pagePath}.html`, + { method: 'HEAD' }, + ); + return resp.ok; +} + +export async function checkOverrides(org, satellites, pagePath) { + const entries = Object.entries(satellites); + const results = await Promise.all( + entries.map(async ([site, info]) => { + const local = await isPageLocal(org, site, pagePath); + return { + site, + label: info.label, + descendantCount: info.descendantCount || 0, + hasOverride: local, + }; + }), + ); + return results; +} + +export function clearMsmCache() { + Object.keys(configCache).forEach((key) => { delete configCache[key]; }); + Object.keys(orgConfigPromises).forEach((key) => { delete orgConfigPromises[key]; }); +} diff --git a/tools/plugins/msm/img/S2_Icon_AlertTriangle_20_N.svg b/tools/plugins/msm/img/S2_Icon_AlertTriangle_20_N.svg new file mode 100644 index 0000000..1e561f8 --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_AlertTriangle_20_N.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/tools/plugins/msm/img/S2_Icon_CheckmarkCircle_20_N.svg b/tools/plugins/msm/img/S2_Icon_CheckmarkCircle_20_N.svg new file mode 100644 index 0000000..d31eee8 --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_CheckmarkCircle_20_N.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/tools/plugins/msm/img/S2_Icon_ChevronRight_20_N.svg b/tools/plugins/msm/img/S2_Icon_ChevronRight_20_N.svg new file mode 100644 index 0000000..caa8c36 --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_ChevronRight_20_N.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/tools/plugins/msm/img/S2_Icon_ClockPending_20_N.svg b/tools/plugins/msm/img/S2_Icon_ClockPending_20_N.svg new file mode 100644 index 0000000..91dd39a --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_ClockPending_20_N.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tools/plugins/msm/img/S2_Icon_Delete_20_N.svg b/tools/plugins/msm/img/S2_Icon_Delete_20_N.svg new file mode 100644 index 0000000..2eb9d24 --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_Delete_20_N.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/tools/plugins/msm/img/S2_Icon_ExperienceAdd_20_N.svg b/tools/plugins/msm/img/S2_Icon_ExperienceAdd_20_N.svg new file mode 100644 index 0000000..b5b07a6 --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_ExperienceAdd_20_N.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tools/plugins/msm/img/S2_Icon_ExperiencePreview_20_N.svg b/tools/plugins/msm/img/S2_Icon_ExperiencePreview_20_N.svg new file mode 100644 index 0000000..03f4ba3 --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_ExperiencePreview_20_N.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tools/plugins/msm/img/S2_Icon_Publish_20_N.svg b/tools/plugins/msm/img/S2_Icon_Publish_20_N.svg new file mode 100644 index 0000000..efd467e --- /dev/null +++ b/tools/plugins/msm/img/S2_Icon_Publish_20_N.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tools/plugins/msm/msm.css b/tools/plugins/msm/msm.css new file mode 100644 index 0000000..1645477 --- /dev/null +++ b/tools/plugins/msm/msm.css @@ -0,0 +1,559 @@ +:host { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; + min-height: 100%; + padding: 12px 12px; + box-sizing: border-box; + + p { margin: 0; } +} + +/* --- Loading / empty states --- */ + +.loading, +.no-satellites { + font-size: 14px; + font-style: italic; + color: var(--s2-gray-600, #717171); +} + +.empty-state { + font-size: 12px; + color: var(--s2-gray-600, #717171); + margin: 0; + font-style: italic; +} + +/* --- Inheritance breadcrumb (static) --- */ + +.crumb-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--s2-gray-700, #4b4b4b); +} + +.crumb-label { + color: var(--s2-gray-600, #717171); + margin-right: 4px; +} + +.crumb-node { + color: var(--s2-gray-800, #292929); +} + +.crumb-node.current { + color: var(--s2-blue-700, #393dba); + font-weight: 600; +} + +.crumb-sep { + color: var(--s2-gray-400, #b1b1b1); +} + +.primary-buttons { + display: flex; + flex-direction: column; + gap: 8px; + align-items: stretch; +} + +.primary-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 0; + height: 32px; + padding: 0 14px; + border-radius: 16px; + font-family: inherit; + font-size: 14px; + font-weight: 500; + line-height: 1; + cursor: pointer; + white-space: nowrap; + background: transparent; + color: var(--s2-gray-800, #292929); + border: 2px solid var(--s2-gray-800, #292929); + transition: + background-color 0.13s ease-in-out, + color 0.13s ease-in-out, + border-color 0.13s ease-in-out, + transform 0.05s ease-in-out; +} + +.primary-btn.fill { + background: var(--s2-gray-800, #292929); + color: #fff; +} + +.primary-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.primary-btn:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; +} + +.primary-btn:active:not(:disabled) { + transform: translateY(0.5px); +} + +.primary-btn.outline:hover:not(:disabled) { + background: var(--s2-gray-100, #f5f5f5); +} + +.primary-btn.fill:hover:not(:disabled) { + background: #000; + border-color: #000; +} + +.primary-btn-icon { + width: 18px; + height: 18px; + flex-shrink: 0; + display: block; +} + +.primary-btn-label { + overflow: hidden; + text-overflow: ellipsis; +} + +/* --- Satellite grid (two columns, matches MSM app) --- */ + +.satellite-grid { + display: flex; + gap: 12px; +} + +.satellite-column { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.column-heading { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500, #929292); + padding-bottom: 4px; + border-bottom: 1px solid var(--s2-gray-200, #e1e1e1); + margin-bottom: 2px; +} + +.satellite-list { + list-style: none; + padding: 0; + margin: 0; + max-height: 140px; + overflow-y: auto; +} + +.satellite-list::-webkit-scrollbar { width: 5px; } +.satellite-list::-webkit-scrollbar-track { background: transparent; } + +.satellite-list::-webkit-scrollbar-thumb { + background: var(--s2-gray-300, #d4d4d4); + border-radius: 3px; +} + +.sat-row { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 2px; + border-radius: 4px; + transition: background-color 0.1s; +} + +.sat-row:hover:not(.out-of-scope) { + background: var(--s2-gray-100, #f5f5f5); +} + +.sat-row.out-of-scope { + opacity: 0.38; +} + +.sat-row label { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + cursor: pointer; + font-size: 12px; + color: var(--s2-gray-900, #292929); +} + +.sat-row label span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sat-row input[type="checkbox"] { + appearance: none; + width: 14px; + height: 14px; + margin: 0; + border: 2px solid var(--s2-gray-600, #717171); + border-radius: 2px; + cursor: pointer; + flex-shrink: 0; + position: relative; + background: transparent; + transition: background-color 0.13s, border-color 0.13s; +} + +.sat-row input[type="checkbox"]:checked { + background: var(--s2-gray-800, #292929); + border-color: var(--s2-gray-800, #292929); +} + +.sat-row input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + inset: 0; + margin: auto; + margin-top: -1px; + width: 4px; + height: 8px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.sat-row input[type="checkbox"]:hover:not(:disabled) { + border-color: var(--s2-gray-800, #292929); +} + +.sat-row input[type="checkbox"]:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.descendant-badge { + display: inline-block; + padding: 0 5px; + font-size: 10px; + font-weight: 600; + color: var(--s2-gray-700, #4b4b4b); + background: var(--s2-gray-200, #e1e1e1); + border-radius: 6px; + flex-shrink: 0; + line-height: 14px; +} + +.edit-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + flex-shrink: 0; + border-radius: 4px; + color: var(--s2-gray-500, #929292); + text-decoration: none; + transition: background-color 0.15s, color 0.15s; +} + +.edit-link:hover { + background: var(--s2-gray-200, #e1e1e1); + color: var(--s2-gray-900, #292929); +} + +.edit-link svg { + width: 14px; + height: 14px; + display: block; +} + +/* --- Status line (upward / satellite view) --- */ + +.status-line { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + padding: 8px 10px; + background: var(--s2-gray-50, #f8f8f8); + border-radius: 6px; +} + +.status-label { + color: var(--s2-gray-700, #4b4b4b); +} + +.status-value { + font-weight: 600; + color: var(--s2-gray-900, #292929); +} + +.status-value.muted { + font-weight: 500; + color: var(--s2-gray-600, #717171); +} + +/* --- Bottom section (direction-flip + advanced + app link) --- */ + +.bottom-section { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 4px; + padding-top: 12px; + border-top: 1px solid var(--s2-gray-200, #e1e1e1); +} + +.direction-flip { + appearance: none; + background: none; + border: none; + padding: 0; + font-family: inherit; + font-size: 12px; + color: var(--s2-gray-700, #4b4b4b); + cursor: pointer; + text-align: left; + width: max-content; + text-decoration: underline; +} + +.direction-flip:disabled { + opacity: 0.5; + cursor: default; + text-decoration: none; +} + +.direction-flip:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; + border-radius: 2px; +} + +.direction-flip:hover:not(:disabled) { + color: var(--s2-gray-900, #292929); +} + +/* --- Advanced expander --- */ + +.advanced-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.advanced-toggle { + appearance: none; + background: none; + border: none; + padding: 0; + font-family: inherit; + font-size: 13px; + color: var(--s2-gray-700, #4b4b4b); + cursor: pointer; + text-align: left; + display: inline-flex; + align-items: center; + gap: 6px; + width: max-content; +} + +.advanced-toggle:hover { + color: var(--s2-gray-900, #292929); +} + +.advanced-toggle:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; + border-radius: 2px; +} + +.advanced-chevron { + display: inline-block; + font-size: 10px; + color: var(--s2-gray-500, #929292); + transition: transform 0.15s ease-in-out; +} + +.advanced-chevron.open { + transform: rotate(90deg); +} + +.advanced-content { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + border: 1px solid var(--s2-gray-200, #e1e1e1); + border-radius: 8px; + background: var(--s2-gray-50, #f8f8f8); +} + +/* --- App deep-link --- */ + +.app-link { + display: inline-flex; + align-items: center; + font-size: 13px; + color: var(--s2-blue-900, #3b63fb); + text-decoration: underline; + width: max-content; +} + +.app-link:hover { + color: var(--s2-blue-1000, #274dea); +} + +.app-link:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; + border-radius: 2px; +} + +/* --- Action row (inside Advanced) --- */ + +.action-row { + display: grid; + + /* Always two equal columns — the action picker stays at 50% width whether + or not the sync-mode picker is visible, so the layout stays stable. */ + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.action-row se-select { + width: 100%; + min-width: 0; +} + +/* --- Advanced hint --- */ + +.advanced-hint { + font-size: 12px; + color: var(--s2-gray-600, #717171); + margin: 0; +} + +.cascade-toggle-inline { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--s2-gray-700, #4b4b4b); +} + +.cascade-toggle-inline input[type="checkbox"] { + margin: 0; + width: 14px; + height: 14px; + accent-color: var(--s2-gray-800, #292929); + cursor: pointer; +} + +.cascade-toggle-inline-label { + cursor: pointer; +} + +/* --- Status icons --- */ + +.result-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.sat-row .result-icon { + width: 14px; + height: 14px; +} + +.result-icon.success { color: #0d6e31; } +.result-icon.error { color: var(--s2-red-900, #d31510); } + +.result-icon.pending { + color: var(--s2-gray-600, #717171); + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* --- Advanced footer (Apply button) --- */ + +.form-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +/* --- Confirm dialog (yellow caution box) --- */ + +.confirm-box { + padding: 12px; + background: var(--s2-yellow-100, #fef9ee); + border: 1px solid var(--s2-yellow-300, #f0dca0); + border-radius: 8px; + font-size: 14px; + color: var(--s2-gray-900, #292929); + + p { + margin: 0 0 10px; + line-height: 1.4; + } + + .confirm-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + } +} + +.confirm-btn { + appearance: none; + border: 1px solid var(--s2-gray-400, #d1d1d1); + border-radius: 4px; + background: #fff; + color: var(--s2-gray-800, #3e3e3e); + font-size: 13px; + font-weight: 500; + font-family: inherit; + padding: 5px 12px; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.15s, border-color 0.15s; + + &:hover { + background: var(--s2-gray-100, #f5f5f5); + border-color: var(--s2-gray-500, #929292); + } + + &:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; + } + + &.danger { + color: var(--s2-red-900, #d31510); + border-color: #f0c8c2; + + &:hover { + background: #fef0ee; + border-color: var(--s2-red-900, #d31510); + } + } +} diff --git a/tools/plugins/msm/msm.html b/tools/plugins/msm/msm.html new file mode 100644 index 0000000..2181645 --- /dev/null +++ b/tools/plugins/msm/msm.html @@ -0,0 +1,27 @@ + + + + + + Multi-site Manager + + + + + + + + diff --git a/tools/plugins/msm/msm.js b/tools/plugins/msm/msm.js new file mode 100644 index 0000000..db891c2 --- /dev/null +++ b/tools/plugins/msm/msm.js @@ -0,0 +1,895 @@ +/* eslint-disable no-underscore-dangle, import/no-unresolved, no-console */ +/* eslint-disable class-methods-use-this */ +import { LitElement, html, nothing } from 'da-lit'; +import DA_SDK from 'https://da.live/nx/utils/sdk.js'; +import { + getSiteConfig, + getSubtreeSatellites, + isPageLocal, + checkOverrides, + setSdkFetch as setConfigSdkFetch, +} from './config.js'; +import { + previewSatellite, + publishSatellite, + createOverride, + deleteOverride, + mergeFromBase, + getSatellitePageStatus, + setSdkFetch as setUtilsSdkFetch, + setEditUrlOrigin, +} from './utils.js'; + +const STATUS = { pending: 'pending', success: 'success', error: 'error' }; +const SYNC_MODE = { override: 'override', merge: 'merge' }; + +const RECURSIVE_ACTIONS = new Set(['preview', 'publish']); +const SYNC_ACTIONS = new Set(['sync', 'sync-from-base']); +const UPWARD_ACTIONS = new Set(['sync-from-base', 'resume-inheritance']); + +const ACTION_SCOPE = { + preview: 'inherited', + publish: 'inherited', + break: 'inherited', + sync: 'custom', + reset: 'custom', +}; + +const MSM_APP_URL = 'https://da.live/app/aemsites/da-blog-tools/tools/apps/msm/msm'; +const ICON_BASE = './img'; +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('[MSM Plugin] Failed to load sl/nexter styles:', e); +} +try { + await import('./vendor/se/components.js'); +} catch (e) { + console.warn(`[MSM Plugin] Failed to load vendored se components: ${e.message}. ` + + 'Falling back to native { if (!outOfScope) this.handleToggle(sat.site); }} /> + ${sat.label} + ${dc > 0 ? html`+${dc}` : nothing} + + ${sat.status ? this.renderStatusIcon(sat.status) : nothing} + ${showEdit ? html` + + + + ` : nothing} + `; + } + + renderAdvancedExpander() { + return html` +
    + + ${this._showAdvanced ? html` +
    +

    Pick which action to apply, then choose the sites in the list above.

    +
    + ${this.renderActionPicker()} + ${this._isSyncMode ? this.renderSyncModeSelect() : nothing} +
    + ${this.renderAdvancedFooter()} +
    + ` : nothing} +
    `; + } + + renderCascadeToggleInline() { + if (!this._asBase || this._isUpwardMode) return nothing; + const totalDescendants = this._inherited.reduce( + (acc, s) => acc + (s.descendantCount || 0), + 0, + ); + if (totalDescendants === 0) return nothing; + const id = 'msm-cascade-toggle'; + const sitesWord = `nested site${totalDescendants === 1 ? '' : 's'}`; + return html` +
    + this._setIncludeDescendants(e.target.checked)}> + +
    `; + } + + renderAdvancedFooter() { + const applyDisabled = this._busy || !this._canApplyDownward; + const count = this._directTargets.length; + const label = count > 0 + ? `Apply to ${count} site${count !== 1 ? 's' : ''}` + : 'Apply'; + + return html` +
    + this.apply()} + ?disabled=${applyDisabled}>${label} +
    `; + } + + renderAppLink() { + return html` + + Manage variants in MSM \u2197 + `; + } + + renderDirectionFlipLink() { + if (!this._hasDualRole) return nothing; + const toUpward = !this._isUpwardMode; + const baseLabel = this._asSatellite?.baseLabel || this._asSatellite?.base; + const label = toUpward + ? `Update from parent (${baseLabel}) instead` + : 'Update children instead'; + const arrow = toUpward ? '\u2191' : '\u2193'; + return html` + `; + } + + renderActionPicker() { + const options = [ + { value: 'break', label: 'Make a local copy on selected sites' }, + { value: 'sync', label: 'Push update to customized sites' }, + { value: 'reset', label: 'Discard local copy on customized sites' }, + ]; + + return html` + this.onActionChange(e.target.value)}> + ${options.map((opt) => html` + + `)} + `; + } + + renderSyncModeSelect() { + return html` + { this._syncMode = e.target.value; }}> + + + `; + } + + renderDownwardView() { + return html` + ${this.renderPrimaryButtons()} + ${this.renderCascadeToggleInline()} + ${this.renderSiteChips()}`; + } + + renderUpwardView() { + return html` + ${this.renderSatelliteStatusLine()} + ${this.renderSatellitePrimaryButtons()}`; + } + + render() { + if (this._loading) { + return html`

    ${this._loading}

    `; + } + + if (!this._asBase && !this._asSatellite) { + return html`

    No satellite sites configured.

    `; + } + + const isUpward = this._showUpwardView; + + return html` + ${this._asSatellite ? this.renderBreadcrumb() : nothing} + ${isUpward ? this.renderUpwardView() : this.renderDownwardView()} + ${this.renderConfirm()} +
    + ${!isUpward && this._asBase ? this.renderAdvancedExpander() : nothing} + ${this._hasDualRole ? this.renderDirectionFlipLink() : nothing} + ${this.renderAppLink()} +
    `; + } +} + +customElements.define('da-msm', DaMsm); + +export default function render(details) { + const cmp = document.createElement('da-msm'); + cmp.details = details; + return cmp; +} + +(async function initAsDialog() { + if (typeof window === 'undefined' || !document.body) return; + + try { + const { context, actions } = await DA_SDK; + + const { org, path, ref } = context; + const site = context.site || context.repo; + console.log('[MSM Plugin] Init context:', { + org, site, path, ref, + }); + console.log('[MSM Plugin] actions.daFetch available?', typeof actions?.daFetch); + + setConfigSdkFetch(actions.daFetch); + setUtilsSdkFetch(actions.daFetch); + + if (document.referrer) { + try { + setEditUrlOrigin(new URL(document.referrer).origin); + } catch { + /* keep default */ + } + } + + const cmp = document.createElement('da-msm'); + cmp.details = { + org, site, path, ref, + }; + document.body.append(cmp); + } catch (error) { + console.error('[MSM Plugin] Initialization error:', error); + const pre = document.createElement('pre'); + pre.style.cssText = 'padding:12px;color:#d31510;font-family:monospace;font-size:12px;'; + pre.textContent = `Failed to initialise MSM plugin: ${error.message}`; + document.body.append(pre); + } +}()); diff --git a/tools/plugins/msm/utils.js b/tools/plugins/msm/utils.js new file mode 100644 index 0000000..eef7992 --- /dev/null +++ b/tools/plugins/msm/utils.js @@ -0,0 +1,110 @@ +/* eslint-disable import/no-unresolved */ +import { DA_ORIGIN } from 'https://da.live/nx/public/utils/constants.js'; + +const AEM_ADMIN = 'https://admin.hlx.page'; + +// NX origin used to dynamically load the `mergeCopy` function. Kept as a +// runtime constant so the plugin can be repointed at a different NX build +// (e.g. for staging) without code changes elsewhere. +const NX = 'https://da.live/nx'; + +// SDK plumbing. The plugin runs inside an iframe served from a different +// origin than da.live, so it can't use IMS-backed fetch directly. The host +// page hands us `actions.daFetch` via DA_SDK and we route every authenticated +// request through it. `setSdkFetch` is called once during plugin init. +let sdkFetch; +export function setSdkFetch(fn) { sdkFetch = fn; } +function daFetch(url, opts) { + if (!sdkFetch) throw new Error('MSM plugin: SDK daFetch not initialised'); + return sdkFetch(url, opts); +} + +export async function previewSatellite(org, satellite, pagePath) { + const aemPath = pagePath.replace('.html', ''); + const url = `${AEM_ADMIN}/preview/${org}/${satellite}/main${aemPath}`; + const resp = await daFetch(url, { method: 'POST' }); + if (!resp.ok) { + const xError = resp.headers?.get('x-error') || `Preview failed (${resp.status})`; + return { error: xError }; + } + return resp.json(); +} + +export async function publishSatellite(org, satellite, pagePath) { + const aemPath = pagePath.replace('.html', ''); + const url = `${AEM_ADMIN}/live/${org}/${satellite}/main${aemPath}`; + const resp = await daFetch(url, { method: 'POST' }); + if (!resp.ok) { + const xError = resp.headers?.get('x-error') || `Publish failed (${resp.status})`; + return { error: xError }; + } + return resp.json(); +} + +export async function createOverride(org, base, satellite, pagePath) { + const basePath = `${DA_ORIGIN}/source/${org}/${base}${pagePath}.html`; + const resp = await daFetch(basePath); + if (!resp.ok) return { error: `Failed to fetch base content (${resp.status})` }; + + const html = await resp.text(); + const blob = new Blob([html], { type: 'text/html' }); + const formData = new FormData(); + formData.append('data', blob); + + const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.html`; + const saveResp = await daFetch(satPath, { method: 'PUT', body: formData }); + if (!saveResp.ok) return { error: `Failed to create override (${saveResp.status})` }; + return { ok: true }; +} + +export async function getSatellitePageStatus(org, satellite, pagePath) { + const aemPath = pagePath.replace('.html', ''); + const url = `${AEM_ADMIN}/status/${org}/${satellite}/main${aemPath}`; + const resp = await daFetch(url); + if (!resp.ok) return { preview: false, live: false }; + const json = await resp.json(); + return { + preview: json.preview?.status === 200, + live: json.live?.status === 200, + }; +} + +export async function deleteOverride(org, satellite, pagePath) { + const satPath = `${DA_ORIGIN}/source/${org}/${satellite}${pagePath}.html`; + const resp = await daFetch(satPath, { method: 'DELETE' }); + if (!resp.ok) return { error: `Failed to delete override (${resp.status})` }; + return { ok: true }; +} + +let mergeCopyFn; +export function setMergeCopy(fn) { mergeCopyFn = fn; } + +// `editUrlOrigin` is the origin used to build the in-editor deep link returned +// to the UI. In the OOTB da-live build it was `window.location.origin`, but +// the plugin's iframe is served from a different origin, so the caller (or +// init code) sets this to the actual da.live host the user is editing on. +let editUrlOrigin = 'https://da.live'; +export function setEditUrlOrigin(origin) { + if (origin) editUrlOrigin = origin; +} +export function getEditUrlOrigin() { return editUrlOrigin; } + +export async function mergeFromBase(org, base, satellite, pagePath) { + try { + const mergeCopy = mergeCopyFn + || (await import(`${NX}/blocks/loc/project/index.js`)).mergeCopy; + + const url = { + source: `/${org}/${base}${pagePath}.html`, + destination: `/${org}/${satellite}${pagePath}.html`, + }; + + const result = await mergeCopy(url, 'MSM Merge'); + if (!result?.ok) return { error: 'Merge failed' }; + + const editUrl = `${editUrlOrigin}/edit#/${org}/${satellite}${pagePath}`; + return { ok: true, editUrl }; + } catch (e) { + return { error: e.message || 'Merge failed' }; + } +} diff --git a/tools/plugins/msm/vendor/se/components.css b/tools/plugins/msm/vendor/se/components.css new file mode 100644 index 0000000..6e04859 --- /dev/null +++ b/tools/plugins/msm/vendor/se/components.css @@ -0,0 +1,470 @@ +/* stylelint-disable */ +/* + * Vendored snapshot of da-nx's nx2/public/se/components.css + * (https://github.com/adobe/da-nx, ravuthu/se-select branch). + * + * The :host { --s2-* } block below provides fallbacks for the design tokens + * that upstream relies on being set by nx2/public/se/styles.css. We don't + * load that global stylesheet, so the values are pinned here so se-select + * renders correctly regardless of host environment. If the host page does + * set these vars, they win via normal CSS inheritance. + * + * To refresh: re-copy from da-nx and re-apply the :host token block. + */ + +:host { + /* Design tokens (mirrors da-nx nx2/styles/styles.css) */ + --s2-gray-50: #f8f8f8; + --s2-gray-75: #f3f3f3; + --s2-gray-100: #e9e9e9; + --s2-gray-200: #e1e1e1; + --s2-gray-300: #dadada; + --s2-gray-400: #c6c6c6; + --s2-gray-500: #8f8f8f; + --s2-gray-600: #717171; + --s2-gray-700: #505050; + --s2-gray-800: #292929; + --s2-gray-900: #131313; + --s2-blue-900: #3b63fb; + --s2-blue-1000: #274dea; + --s2-red-200: #ffebe8; + --s2-red-800: #ea3829; + --s2-red-900: #d73220; + --s2-font-family: "Adobe Clean", adobe-clean, "Trebuchet MS", sans-serif; + --s2-body-size-xs: 12px; + --s2-component-m-regular-font-size: 14px; + --s2-corner-radius-300: 6px; + --s2-corner-radius-500: 8px; + --s2-corner-radius-800: 16px; + + display: block; + position: relative; + + --sl-field-height: 28px; + --sl-field-border: 2px solid var(--s2-gray-200); + --sl-fixed-family: "Roboto Mono", menlo, consolas, "Liberation Mono", monospace; +} + +:host > svg { + display: none; +} + +svg.icon { + width: 20px; + height: 20px; +} + +.sl-inputarea { + min-height: 64px; +} + +.sl-inputfield { + label { + font-size: var(--s2-body-size-xs); + display: block; + color: var(--s2-gray-500); + margin-bottom: 4px; + } + + input[type="date"] { + padding: 0 8px 0 12px; + } + + textarea { + position: absolute; + inset: 0; + } + + input[type="text"], + input[type="number"], + input[type="password"], + input[type="date"], + input[type="datetime-local"], + textarea { + width: 100%; + display: block; + padding: 0 12px; + background: var(--s2-gray-50); + line-height: var(--sl-field-height); + font-family: var(--s2-font-family); + font-size: var(--s2-component-m-regular-font-size); + border-radius: var(--s2-corner-radius-500); + outline-color: var(--s2-blue-900); + outline-offset: 0; + transition: outline-offset 0.2s; + border: var(--sl-field-border); + box-sizing: border-box; + + &.quiet { + background: transparent; + border: none; + padding: 0; + } + + &.monospace { + font-family: var(--fixed-family); + } + + &.has-label { + top: 22px; /* match label height */ + } + + &.has-error { + border: 2px solid var(--s2-red-900); + } + + &:disabled { + opacity: 1; + background: var(--s2-gray-75); + border: none; + color: var(--s2-gray-500); + } + + &[resize="none"] { + resize: none; + } + } + + input[type="range"] { + display: block; + position: relative; + appearance: none; + width: 100%; + outline: none; + background: transparent; + margin: 0; + } + + input[type="range"]::after { + content: ""; + height: 2px; + border-radius: 1px; + background: rgb(143 143 143); + position: absolute; + left: 0; + right: 0; + top: 50%; + } + + input[type="range"]:focus-visible::after { + background: var(--s2-blue-900); + margin-top: -1px; + height: 3px; + } + + input[type="range"]::-webkit-slider-thumb { + position: relative; + appearance: none; + border: 2px solid #464646; + margin-top: 2px; + background: #fff; + width: 16px; + height: 16px; + border-radius: 8px; + z-index: 1; + } + + input:focus-visible, + textarea:focus-visible { + outline-offset: 4px; + } + + .sl-inputfield-error { + font-size: var(--s2-body-size-xs); + color: var(--s2-red-900); + margin: 0; + } +} + +.sl-checkbox { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 8px; + + input[type="checkbox"] { + flex-shrink: 0; + margin: 0; + } + + input[type="checkbox"].has-error { + outline: 2px solid var(--s2-red-900); + outline-offset: 2px; + } + + label { + font-family: var(--s2-font-family); + font-size: var(--s2-body-size-xs); + color: rgb(80 80 80); + margin-bottom: 0; + order: 1; + cursor: pointer; + } + + .sl-inputfield-error { + flex-basis: 100%; + order: 2; + font-size: var(--s2-body-size-xs); + color: var(--s2-red-900); + margin: 0; + } +} + +.sl-button { + button { + display: flex; + justify-content: center; + min-width: 34px; + gap: 6px; + padding: 5px 14px; + line-height: 18px; + font-size: 14px; + color: #fff; + background: var(--s2-blue-900); + border: 2px solid var(--s2-blue-900); + font-family: var(--s2-font-family); + border-radius: var(--s2-corner-radius-800); + outline-color: var(--s2-blue-900); + outline-offset: 0; + transition: outline-offset 0.2s; + text-decoration: none; + font-weight: 700; + text-align: center; + cursor: pointer; + + &:focus-visible { + outline-offset: 4px; + } + + &:hover { + background: var(--s2-blue-1000); + border-color: var(--s2-blue-1000); + } + + &.negative { + background: var(--s2-red-900); + border-color: var(--s2-red-900); + outline-color: var(--s2-red-900); + + &:hover { + background: var(--s2-red-800); + border-color: var(--s2-red-800); + } + + &.outline { + background: transparent; + color: var(--s2-red-900); + + &:hover { + background: var(--s2-red-200); + border-color: var(--s2-red-800); + color: var(--s2-red-800); + } + + &:focus-visible { + color: #fff; + background: var(--s2-red-900); + border-color: var(--s2-red-900); + } + } + } + + &.icon-only { + padding: 5px 0; + } + + &.primary { + background: var(--s2-gray-800); + border: 2px solid var(--s2-gray-800); + color: #fff; + + &:hover { + background: var(--s2-gray-900); + border-color: var(--s2-gray-900); + } + + &.outline { + background: transparent; + color: var(--s2-gray-800); + + &:hover { + background: transparent; + border-color: var(--s2-gray-600); + color: var(--s2-gray-600); + } + } + } + + &:disabled { + opacity: 0.6; + pointer-events: none; + } + } +} + +.sl-dialog { + display: block; + visibility: hidden; + padding: 0; + border: none; + border-radius: 16px; + z-index: 100000; + transform: translateY(12px); + opacity: 0; + transition: + opacity 0.4s cubic-bezier(0, 0, 0.4, 1), + transform 0.4s cubic-bezier(0, 0, 0.4, 1); + box-shadow: + 0 0 6px 0 rgb(0 0 0 / 24%), + 0 3px 20px 0 rgb(0 0 0 / 16%), + 0 4px 20px 0 rgb(0 0 0 / 24%); + + &.overflow-hidden { + overflow: hidden; + } + + &[open] { + visibility: visible; + opacity: 1; + transform: translateY(0); + } + + &::backdrop { + background-color: rgb(0 0 0 / 60%); + } +} + +.sl-select { + position: relative; + width: 100%; +} + +.sl-select::after { + content: ""; + position: absolute; + top: 0; + right: 0; + width: 32px; + height: 32px; + background: no-repeat center / 18px url("https://da.live/nx/public/icons/Smock_ChevronDown_18_N.svg"); + pointer-events: none; + transition: transform 0.15s; +} + +.sl-select.open::after { + transform: rotate(180deg); +} + +.sl-select:has(.sl-select-trigger:disabled)::after { + display: none; +} + +.sl-select-trigger { + display: block; + width: 100%; + padding: 0 32px 0 12px; + background: var(--s2-gray-200); + font-family: var(--s2-font-family); + font-size: var(--s2-component-m-regular-font-size); + line-height: var(--sl-field-height); + color: var(--s2-gray-800); + border: 2px solid var(--s2-gray-200); + border-radius: var(--s2-corner-radius-500); + outline-color: var(--s2-blue-900); + outline-offset: 0; + transition: outline-offset 0.2s; + text-align: left; + cursor: pointer; + box-sizing: border-box; +} + +.sl-select-trigger:focus-visible { + outline-offset: 4px; +} + +.sl-select-trigger:disabled { + opacity: 1; + background: var(--s2-gray-75); + border: none; + color: var(--s2-gray-500); + cursor: default; +} + +.sl-select.has-error .sl-select-trigger { + border-color: var(--s2-red-900); +} + +.sl-select-label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sl-select-label.is-placeholder { + color: var(--s2-gray-500); +} + +.sl-select-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 10; + list-style: none; + margin: 0; + padding: 6px; + background: light-dark(#fff, var(--s2-gray-100)); + border: 1px solid var(--s2-gray-200); + border-radius: var(--s2-corner-radius-500); + box-shadow: 0 4px 12px rgb(0 0 0 / 12%); + max-height: 320px; + overflow-y: auto; +} + +.sl-select-group { + font-family: var(--s2-font-family); + font-size: var(--s2-body-size-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500); + padding: 8px 10px 4px; + user-select: none; +} + +.sl-select-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + font-family: var(--s2-font-family); + font-size: var(--s2-component-m-regular-font-size); + color: var(--s2-gray-800); + border-radius: var(--s2-corner-radius-300); + cursor: pointer; +} + +.sl-select-item.active { + background: var(--s2-gray-200); +} + +.sl-select-menu:focus-visible { + outline: none; +} + +.sl-select-check { + width: 14px; + height: 14px; + visibility: hidden; +} + +.sl-select-item.selected .sl-select-check { + visibility: visible; +} + +.sl-select-item.disabled { + color: var(--s2-gray-500); + cursor: default; + pointer-events: none; +} diff --git a/tools/plugins/msm/vendor/se/components.js b/tools/plugins/msm/vendor/se/components.js new file mode 100644 index 0000000..54733a7 --- /dev/null +++ b/tools/plugins/msm/vendor/se/components.js @@ -0,0 +1,555 @@ +/* eslint-disable */ + +import { LitElement, html, nothing, spread } from 'https://da.live/deps/lit/dist/index.js'; + +const loadStyle = async (url) => { + const cssUrl = url.replace(/\.js(\?.*)?$/, '.css'); + const resp = await fetch(cssUrl); + const text = await resp.text(); + const sheet = new CSSStyleSheet(); + sheet.replaceSync(text); + return sheet; +}; + +const style = await loadStyle(import.meta.url); + +class SlInput extends LitElement { + static formAssociated = true; + + static properties = { + value: { type: String }, + class: { type: String }, + label: { type: String }, + error: { type: String }, + name: { type: String }, + }; + + constructor() { + super(); + this._internals = this.attachInternals(); + } + + async connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + this._internals.setFormValue(this.value); + } + + focus() { + this.shadowRoot.querySelector('input').focus(); + } + + handleEvent(event) { + this.value = event.target.value; + this._internals.setFormValue(this.value); + const wcEvent = new event.constructor(event.type, event); + this.dispatchEvent(wcEvent); + } + + handleKeyDown(event) { + if (event.key !== 'Enter') return; + if (!this.form) return; + + const submitEvent = new SubmitEvent('submit', { bubbles: true, cancelable: true }); + this.form.dispatchEvent(submitEvent); + + if (submitEvent.defaultPrevented) return; + this.form.submit(); + } + + get _attrs() { + return this.getAttributeNames().reduce((acc, name) => { + if ((name === 'class' || name === 'label' || name === 'value' || name === 'error')) return acc; + acc[name] = this.getAttribute(name); + return acc; + }, {}); + } + + get form() { return this._internals.form; } + + render() { + return html` +
    + ${this.label ? html`` : nothing} + + ${this.error ? html`

    ${this.error}

    ` : nothing} +
    + `; + } +} + +class SlTextarea extends LitElement { + static formAssociated = true; + + static properties = { + value: { type: String }, + class: { type: String }, + label: { type: String }, + error: { type: String }, + name: { type: String }, + }; + + constructor() { + super(); + this._internals = this.attachInternals(); + } + + async connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + this._internals.setFormValue(this.value); + } + + handleEvent(event) { + this.value = event.target.value; + this._internals.setFormValue(this.value); + const wcEvent = new event.constructor(event.type, event); + this.dispatchEvent(wcEvent); + } + + get form() { return this._internals.form; } + + get _attrs() { + return this.getAttributeNames().reduce((acc, name) => { + if ((name === 'class' || name === 'label' || name === 'value' || name === 'error')) return acc; + acc[name] = this.getAttribute(name); + return acc; + }, {}); + } + + render() { + return html` +
    + ${this.label ? html`` : nothing} + + ${this.error ? html`

    ${this.error}

    ` : nothing} +
    + `; + } +} + +class SlCheckbox extends LitElement { + static formAssociated = true; + + static properties = { + name: { type: String }, + checked: { type: Boolean }, + error: { type: String }, + }; + + constructor() { + super(); + this._internals = this.attachInternals(); + } + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + this._updateFormValue(); + } + + get type() { + return 'checkbox'; + } + + get value() { + return this.checked ? 'true' : ''; + } + + _updateFormValue() { + if (this.checked) { + this._internals.setFormValue('true'); + } else { + this._internals.setFormValue(''); + } + } + + handleChange(event) { + this.checked = event.target.checked; + this._updateFormValue(); + const wcEvent = new event.constructor(event.type, { bubbles: true, composed: true }); + this.dispatchEvent(wcEvent); + } + + render() { + return html` +
    + + + ${this.error ? html`

    ${this.error}

    ` : nothing} +
    + `; + } +} + +class SlSelect extends LitElement { + static formAssociated = true; + + static readOption(opt) { + return { + value: opt.value, + label: opt.textContent.trim(), + disabled: opt.hasAttribute('disabled'), + }; + } + + static properties = { + name: { type: String }, + label: { type: String }, + value: { type: String }, + disabled: { type: Boolean }, + placeholder: { type: String }, + error: { type: String }, + _open: { state: true }, + _activeIndex: { state: true }, + _groups: { state: true }, + }; + + constructor() { + super(); + this._open = false; + this._activeIndex = -1; + } + + _restoreFocusOnClose = false; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + this._internals = this.attachInternals(); + this._internals.setFormValue(this.value); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._removeOutsideListeners(); + } + + update(props) { + if (props.has('value')) this._internals.setFormValue(this.value); + if (props.has('_open')) { + if (this._open) this._addOutsideListeners(); + else this._removeOutsideListeners(); + } + super.update(props); + } + + updated(props) { + if (props.has('_open')) { + if (this._open) { + this.shadowRoot.querySelector('.sl-select-menu')?.focus(); + this._scrollActiveIntoView(); + } else if (this._restoreFocusOnClose) { + this._restoreFocusOnClose = false; + this.shadowRoot.querySelector('.sl-select-trigger')?.focus(); + } + } + if (props.has('_activeIndex') && this._open) this._scrollActiveIntoView(); + } + + _scrollActiveIntoView() { + const items = this.shadowRoot.querySelectorAll('.sl-select-item'); + items[this._activeIndex]?.scrollIntoView({ block: 'nearest' }); + } + + handleSlotchange(e) { + this._buildGroups(e.target.assignedNodes({ flatten: true })); + } + + _buildGroups(nodes) { + const groups = nodes.reduce((acc, node) => { + if (node.nodeName === 'OPTGROUP') { + const group = { + heading: node.label || '', + items: [...node.querySelectorAll('option')].map(SlSelect.readOption), + }; + return [...acc, group]; + } + if (node.nodeName === 'OPTION') { + const last = acc[acc.length - 1]; + const updated = { ...last, items: [...last.items, SlSelect.readOption(node)] }; + return [...acc.slice(0, -1), updated]; + } + return acc; + }, [{ heading: null, items: [] }]); + + this._groups = groups.filter((g) => g.items.length > 0); + + if (!this.value) { + const first = this._flatOptions().find((o) => !o.disabled); + if (first) { + this.value = first.value; + this._internals.setFormValue(this.value); + } + } + } + + _flatOptions() { + return this._groups?.flatMap((g) => g.items) ?? []; + } + + _selectedLabel() { + const match = this._flatOptions().find((o) => o.value === this.value); + return match?.label ?? this.placeholder ?? ''; + } + + _toggle() { + if (this.disabled) return; + this._open = !this._open; + if (this._open) this._activeIndex = -1; + } + + _close({ returnFocus = false } = {}) { + if (returnFocus) this._restoreFocusOnClose = true; + this._open = false; + } + + _addOutsideListeners() { + document.addEventListener('pointerdown', this._onDocPointerDown, true); + } + + _removeOutsideListeners() { + document.removeEventListener('pointerdown', this._onDocPointerDown, true); + } + + _onDocPointerDown = (e) => { + if (!e.composedPath().includes(this)) this._close(); + }; + + _selectValue(value) { + if (this.value !== value) { + this.value = value; + this._internals.setFormValue(value); + this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + } + this._close({ returnFocus: true }); + } + + _onTriggerKeydown(e) { + if (this.disabled) return; + if (this._open) { + if (e.key === 'Escape') { + e.preventDefault(); + this._close({ returnFocus: true }); + } + return; + } + if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) { + e.preventDefault(); + this._toggle(); + } + } + + _onMenuKeydown(e) { + const flat = this._flatOptions(); + const moveTo = (start, dir) => { + let i = start; + for (let n = 0; n < flat.length; n += 1) { + i = (i + dir + flat.length) % flat.length; + if (!flat[i].disabled) return i; + } + return start; + }; + if (e.key === 'ArrowDown') { + e.preventDefault(); + this._activeIndex = moveTo(this._activeIndex, 1); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + this._activeIndex = moveTo(this._activeIndex, -1); + } else if (e.key === 'Home') { + e.preventDefault(); + this._activeIndex = moveTo(-1, 1); + } else if (e.key === 'End') { + e.preventDefault(); + this._activeIndex = moveTo(0, -1); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + const opt = flat[this._activeIndex]; + if (opt && !opt.disabled) this._selectValue(opt.value); + } else if (e.key === 'Escape' || e.key === 'Tab') { + e.preventDefault(); + this._close({ returnFocus: true }); + } + } + + render() { + const selectedLabel = this._selectedLabel(); + let runningIndex = -1; + return html` + +
    + ${this.label ? html`` : nothing} +
    + + ${this._open ? html` +
      + ${this._groups?.map((group) => html` + ${group.heading ? html` + + ` : nothing} + ${group.items.map((opt) => { + runningIndex += 1; + const idx = runningIndex; + const isActive = idx === this._activeIndex; + const isSelected = opt.value === this.value; + return html` +
    • { this._activeIndex = idx; }} + @click=${() => !opt.disabled && this._selectValue(opt.value)}> + + + + ${opt.label} +
    • `; + })} + `)} +
    + ` : nothing} +
    + ${this.error ? html`

    ${this.error}

    ` : nothing} +
    + `; + } +} + +class SlButton extends LitElement { + static formAssociated = true; + + static properties = { + class: { type: String }, + disabled: { type: Boolean }, + type: { type: String }, + }; + + constructor() { + super(); + this._internals = this.attachInternals(); + this.type = 'button'; + } + + async connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + } + + get _attrs() { + return this.getAttributeNames().reduce((acc, name) => { + if ((name === 'class' || name === 'label' || name === 'disabled' || name === 'type')) return acc; + acc[name] = this.getAttribute(name); + return acc; + }, {}); + } + + handleClick() { + if (this.disabled) return; + const { form } = this._internals; + if (!form) return; + if (this.type === 'submit') form.requestSubmit(); + else if (this.type === 'reset') form.reset(); + } + + render() { + return html` + + + `; + } +} + +class SlDialog extends LitElement { + static properties = { + open: { type: Boolean }, + modal: { type: Boolean }, + overflow: { type: String }, + _showLazyModal: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + } + + updated() { + if (this._showLazyModal && this._dialog) { + this._showLazyModal = undefined; + this.showModal(); + } + } + + showModal() { + if (!this._dialog) { + this._showLazyModal = true; + return; + } + this._dialog.showModal(); + } + + show() { + this._dialog.show(); + } + + close() { + this._dialog.close(); + } + + onClose(e) { + this.dispatchEvent(new Event('close', e)); + } + + get _dialog() { + return this.shadowRoot.querySelector('dialog'); + } + + render() { + return html` + + + `; + } +} + +if (!customElements.get('se-input')) customElements.define('se-input', SlInput); +if (!customElements.get('se-textarea')) customElements.define('se-textarea', SlTextarea); +if (!customElements.get('se-checkbox')) customElements.define('se-checkbox', SlCheckbox); +if (!customElements.get('se-select')) customElements.define('se-select', SlSelect); +if (!customElements.get('se-button')) customElements.define('se-button', SlButton); +if (!customElements.get('se-dialog')) customElements.define('se-dialog', SlDialog); From f5c4c2ef1c478a8c22da2d1101d9c3977b6c9d86 Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Fri, 22 May 2026 10:38:34 -0400 Subject: [PATCH 18/26] highlight the nested page path --- tools/apps/msm/helpers/column-browser.css | 16 ++++ tools/apps/msm/helpers/column-browser.js | 102 +++++++++++++++++----- 2 files changed, 98 insertions(+), 20 deletions(-) diff --git a/tools/apps/msm/helpers/column-browser.css b/tools/apps/msm/helpers/column-browser.css index ef40f95..51a2b1f 100644 --- a/tools/apps/msm/helpers/column-browser.css +++ b/tools/apps/msm/helpers/column-browser.css @@ -108,6 +108,22 @@ background: var(--s2-blue-200, #cce0ff); } +/* Ancestor folders on the path to the current column (previous columns) */ +.item.path-ancestor .item-label { + font-weight: 700; + color: var(--s2-gray-900); +} + +.item.path-ancestor.selected { + background: var(--s2-gray-100, #f0f0f0); + border-left: 3px solid var(--s2-gray-700, #4b4b4b); + padding-left: 9px; +} + +.item.path-ancestor.selected:hover { + background: var(--s2-gray-200, #e1e1e1); +} + .item.focused { outline: 2px solid var(--s2-blue-900); outline-offset: -2px; diff --git a/tools/apps/msm/helpers/column-browser.js b/tools/apps/msm/helpers/column-browser.js index f252eec..5139e35 100644 --- a/tools/apps/msm/helpers/column-browser.js +++ b/tools/apps/msm/helpers/column-browser.js @@ -212,29 +212,85 @@ class MsmColumnBrowser extends LitElement { } } + _findItemElement(colIdx, path, site = '') { + const lists = this.shadowRoot.querySelectorAll('.column .column-items'); + const list = lists[colIdx]; + if (!list) return null; + return Array.from(list.querySelectorAll('.item')).find((el) => ( + el.dataset.path === path + && (el.dataset.site || '') === (site || '') + )) || null; + } + + scrollItemIntoView(colIdx, path, site = '', { block = 'nearest', behavior = 'auto' } = {}) { + return this.updateComplete.then(() => new Promise((resolve) => { + requestAnimationFrame(() => { + const target = this._findItemElement(colIdx, path, site); + if (target) { + target.scrollIntoView({ block, behavior, inline: 'nearest' }); + } + resolve(); + }); + })); + } + scrollFocusedIntoView() { - this.updateComplete.then(() => { - const f = this._focusedItem; - if (!f) return; - const els = this.shadowRoot.querySelectorAll( - `.column:nth-child(${f.columnIdx + 1}) .item`, - ); - const target = Array.from(els).find((el) => ( - el.dataset.path === f.path - && (el.dataset.site || '') === f.site - )); - if (target) target.scrollIntoView({ block: 'nearest' }); - }); + const f = this._focusedItem; + if (!f) return this.updateComplete; + return this.scrollItemIntoView(f.columnIdx, f.path, f.site); } - scrollToActiveColumn() { - this.updateComplete.then(() => { - if (window.innerWidth <= 600) return; - const browser = this.shadowRoot.querySelector('.browser'); - if (browser) { - browser.scrollTo({ left: browser.scrollWidth, behavior: 'smooth' }); + // Scroll the current selection into view (focused, checked, or path highlight). + // Used after deep-link navigation when the target row may be below the fold. + scrollSelectionIntoView({ block = 'center', behavior = 'smooth' } = {}) { + if (this._focusedItem) { + const { columnIdx, path, site } = this._focusedItem; + return this.scrollItemIntoView(columnIdx, path, site, { block, behavior }); + } + + for (let c = this._columns.length - 1; c >= 0; c -= 1) { + const col = this._columns[c]; + const visible = this._visibleItems(col); + const checked = visible.find((item) => this.isItemChecked(item)); + if (checked) { + return this.scrollItemIntoView( + c, + checked.path, + checked.site || '', + { block, behavior }, + ); } - }); + } + + const highlighted = [...this._columns].reverse().find((col) => col.selectedPath); + if (highlighted) { + const item = highlighted.items.find((it) => it.path === highlighted.selectedPath); + if (item) { + const colIdx = this._columns.indexOf(highlighted); + return this.scrollItemIntoView( + colIdx, + item.path, + item.site || '', + { block, behavior }, + ); + } + } + + return this.updateComplete; + } + + scrollToActiveColumn() { + return this.updateComplete.then(() => new Promise((resolve) => { + requestAnimationFrame(() => { + if (window.innerWidth > 600) { + const browser = this.shadowRoot.querySelector('.browser'); + if (browser) { + browser.scrollTo({ left: browser.scrollWidth, behavior: 'smooth' }); + } + } + resolve(); + }); + })); } toggleCheck(item, colIdx) { @@ -376,6 +432,9 @@ class MsmColumnBrowser extends LitElement { bubbles: true, composed: true, })); + + await this.scrollToActiveColumn(); + await this.scrollSelectionIntoView({ block: 'center', behavior: 'smooth' }); } async navigateToFolder(colIdx, item) { @@ -594,6 +653,9 @@ class MsmColumnBrowser extends LitElement { renderItem(colIdx, item) { const isSelected = this._columns[colIdx]?.selectedPath === item.path; + const isPathAncestor = isSelected + && (item.isFolder || item.isSite) + && colIdx < this._columns.length - 1; const f = this._focusedItem; const isFocused = !!f && f.columnIdx === colIdx @@ -609,7 +671,7 @@ class MsmColumnBrowser extends LitElement { const title = tooltipParts.join(' \u2022 ') || undefined; return html`
    this.handleItemClick(colIdx, item, e)} From dddc6f3841302957adbc46efafafb911bc5fdccd Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Fri, 22 May 2026 11:22:51 -0400 Subject: [PATCH 19/26] use the msm-app branch for app deeplink temporarily --- tools/plugins/msm/msm.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/plugins/msm/msm.js b/tools/plugins/msm/msm.js index ad5ed6b..399d2ca 100644 --- a/tools/plugins/msm/msm.js +++ b/tools/plugins/msm/msm.js @@ -220,10 +220,11 @@ class DaMsm extends LitElement { _getAppDeepLink() { const { - org, site, path, ref, + org, site, path, } = this.details; const params = new URLSearchParams({ org, site, path }); - if (ref) params.set('ref', ref); + //TODO: remove this once msm-app branch is merged to main + params.set('ref', 'msm-app'); return `${MSM_APP_URL}?${params.toString()}`; } From 697fec9277f9953d2ecdb1880d13557644e6bc8f Mon Sep 17 00:00:00 2001 From: Ramachandra Avuthu Date: Fri, 22 May 2026 11:52:33 -0400 Subject: [PATCH 20/26] msm app fixes --- tools/apps/msm/helpers/action-panel.css | 54 +++++- tools/apps/msm/helpers/action-panel.js | 227 ++++++++++++++++++------ tools/apps/msm/helpers/api.js | 12 ++ 3 files changed, 237 insertions(+), 56 deletions(-) diff --git a/tools/apps/msm/helpers/action-panel.css b/tools/apps/msm/helpers/action-panel.css index bcd24f9..b20b660 100644 --- a/tools/apps/msm/helpers/action-panel.css +++ b/tools/apps/msm/helpers/action-panel.css @@ -184,6 +184,35 @@ background: rgb(255 255 255 / 60%); } +/* ===== Descendant toggle (reuses expand-toggle) ===== */ + +.sat-descendant-toggle { + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 2px; +} + +.sat-descendant-toggle .expand-toggle { + width: 10px; + height: 10px; +} + +.sat-expand-content { + padding: 2px 4px 6px 30px; + font-size: 13px; + color: var(--s2-gray-700); +} + +.sat-expand-content .expand-list li { + padding: 2px 0; +} + +.sat-expand-content .expand-list li.affected { + color: var(--s2-blue-700, #393dba); + font-weight: 500; +} + /* ===== Footer cascade toggle ===== */ .footer-cascade { @@ -196,6 +225,11 @@ user-select: none; } +.footer-cascade.muted { + color: var(--s2-gray-600, #717171); + cursor: default; +} + .footer-spacer { flex: 1; } @@ -532,7 +566,7 @@ list-style: none; padding: 0; margin: 0; - max-height: 200px; + max-height: min(220px, 40vh); overflow-y: auto; } @@ -544,6 +578,18 @@ border-radius: 3px; } +.sat-row-wrap { + list-style: none; +} + +.sat-row-wrap.out-of-scope { + opacity: 0.38; +} + +.satellite-filter-grid { + margin-bottom: 4px; +} + .sat-row { display: flex; align-items: center; @@ -553,14 +599,10 @@ transition: background-color 0.1s; } -.sat-row:hover:not(.out-of-scope) { +.sat-row-wrap:hover:not(.out-of-scope) .sat-row { background: var(--s2-gray-100, #f5f5f5); } -.sat-row.out-of-scope { - opacity: 0.38; -} - .sat-row label { display: flex; align-items: center; diff --git a/tools/apps/msm/helpers/action-panel.js b/tools/apps/msm/helpers/action-panel.js index bb067c0..4d3c6eb 100644 --- a/tools/apps/msm/helpers/action-panel.js +++ b/tools/apps/msm/helpers/action-panel.js @@ -136,6 +136,7 @@ class MsmActionPanel extends LitElement { _busy: { state: true }, _includedPages: { state: true }, _includeDescendants: { state: true }, + _expandedNested: { state: true }, }; connectedCallback() { @@ -154,7 +155,8 @@ class MsmActionPanel extends LitElement { this._taskStatuses = new Map(); this._busy = false; this._includedPages = new Set(this.pages?.map((p) => p.path) || []); - this._includeDescendants = false; + this._includeDescendants = true; + this._expandedNested = new Set(); this._lastPageKey = ''; this._handleOutsideClick = this._handleOutsideClick.bind(this); } @@ -330,6 +332,13 @@ class MsmActionPanel extends LitElement { this._resetExecution(); } + _toggleNestedExpand(satSite) { + const next = new Set(this._expandedNested); + if (next.has(satSite)) next.delete(satSite); + else next.add(satSite); + this._expandedNested = next; + } + // ── Per-page action ── getPageAction(pagePath) { @@ -429,25 +438,69 @@ class MsmActionPanel extends LitElement { )).reduce((acc, [s, info]) => { acc[s] = info; return acc; }, {}); } - get _totalDescendants() { - return Object.values(this.satellites || {}) - .reduce((acc, info) => acc + (info.descendantCount || 0), 0); + get _activeSelectedSats() { + return this.isSinglePage ? this._singleSelectedSats : this._selectedSats; + } + + /** + * Nested sites that would receive rollout for currently selected + * **inherited** (following-base) satellites only. Custom satellites + * already have a local copy and are not part of recursive rollout. + */ + get _selectedCascadeDescendantCount() { + const selectedSats = this._activeSelectedSats; + const overrides = this.isSinglePage + ? this.getPageOverrides(this.pages[0]?.path) + : []; + return Object.entries(this.satellites || {}) + .filter(([site]) => { + if (!selectedSats.has(site)) return false; + const ov = overrides.find((o) => o.site === site); + return !ov || !ov.hasOverride; + }) + .reduce((acc, [, info]) => acc + (info.descendantCount || 0), 0); + } + + _cascadeAppliesToSelection() { + return RECURSIVE_ACTIONS.has(this._globalAction) + && this._includeDescendants + && this._selectedCascadeDescendantCount > 0; + } + + _cascadeToggleHint() { + const count = this._selectedCascadeDescendantCount; + const selectedSats = this._activeSelectedSats; + const noneSelected = selectedSats.size === 0 + && Object.keys(this.satellites || {}).length > 0; + if (noneSelected) return 'Select sites above to include their nested satellites'; + if (count === 0) return 'Selected sites have no nested satellites'; + return `Also roll out to ${count} nested site${count === 1 ? '' : 's'}`; } get _showDescendantsToggle() { return !this._isUpwardMode && this._hasDownwardActions && this.hasDescendants - && RECURSIVE_ACTIONS.has(this._globalAction) - && this._totalDescendants > 0; + && RECURSIVE_ACTIONS.has(this._globalAction); } - resolveSatellitesForAction(directSatellites, action) { + resolveSatellitesForAction(directSatellites, action, pageOverrides) { if (!this._includeDescendants || !RECURSIVE_ACTIONS.has(action)) { return directSatellites; } if (!this.msmConfig) return directSatellites; - return expandSatellitesWithSubtree(this.msmConfig, directSatellites); + const scope = ACTION_SCOPE[action]; + const inScopeSats = scope + ? Object.entries(directSatellites).reduce((acc, [site, info]) => { + const ov = pageOverrides?.find((o) => o.site === site); + const hasOverride = ov?.hasOverride ?? false; + const matches = scope === 'custom' ? hasOverride : !hasOverride; + if (matches) acc[site] = info; + return acc; + }, {}) + : directSatellites; + const expanded = expandSatellitesWithSubtree(this.msmConfig, inScopeSats); + return { ...directSatellites, ...expanded }; } // ── Execution ── @@ -487,8 +540,9 @@ class MsmActionPanel extends LitElement { const skipNote = ' Sites that don\'t match the action scope will be skipped.'; const recursiveActive = this._includeDescendants && Object.keys(counts).some((a) => RECURSIVE_ACTIONS.has(a)); - const recursiveNote = recursiveActive - ? ` Also roll out to ${this._totalDescendants} nested site${this._totalDescendants !== 1 ? 's' : ''}.` + const nestedCount = this._selectedCascadeDescendantCount; + const recursiveNote = recursiveActive && nestedCount > 0 + ? ` Also roll out to ${nestedCount} nested site${nestedCount !== 1 ? 's' : ''}.` : ''; return `${parts.join(', ')} ${satSuffix}.${skipNote}${recursiveNote} Continue?`; } @@ -589,7 +643,10 @@ class MsmActionPanel extends LitElement { } // Downward: current site is the base, children are the satellites. const filteredSats = this.getFilteredSatellites(); - const sats = this.resolveSatellitesForAction(filteredSats, action); + const pageOverrides = this.pages?.length === 1 + ? this.getPageOverrides(this.pages[0].path) + : []; + const sats = this.resolveSatellitesForAction(filteredSats, action, pageOverrides); return { baseSite: this.site, satellites: sats, @@ -608,7 +665,10 @@ class MsmActionPanel extends LitElement { const directSats = Object.entries(this.satellites || {}) .filter(([s]) => satSet.has(s)) .reduce((acc, [s, info]) => { acc[s] = info; return acc; }, {}); - const sats = this.resolveSatellitesForAction(directSats, action); + const pageOverrides = this.pages?.length === 1 + ? this.getPageOverrides(this.pages[0].path) + : []; + const sats = this.resolveSatellitesForAction(directSats, action, pageOverrides); return { baseSite: this.site, satellites: sats, @@ -938,15 +998,15 @@ class MsmActionPanel extends LitElement { return html`
    ${this._showDescendantsToggle ? html` -