diff --git a/blocks/canvas/editor-utils/prepare-menu.js b/blocks/canvas/editor-utils/prepare-menu.js index ea5da1591..f4f7f95a6 100644 --- a/blocks/canvas/editor-utils/prepare-menu.js +++ b/blocks/canvas/editor-utils/prepare-menu.js @@ -30,6 +30,12 @@ const OOTB_ACTIONS = [ icon: '/img/icons/s2-icon-target-20-n.svg#icon', optional: true, }, + { + title: 'Multi-site Manager', + render: async (details) => (await import('../../edit/da-prepare/actions/msm/msm.js')).default(details), + icon: '/blocks/edit/img/S2_Icon_GlobeGrid_20_N.svg#S2_Icon_GlobeGrid', + optional: true, + }, ]; export default class PrepareMenu extends LitElement { diff --git a/blocks/edit/da-prepare/actions/msm/README.md b/blocks/edit/da-prepare/actions/msm/README.md new file mode 100644 index 000000000..6a4d96d33 --- /dev/null +++ b/blocks/edit/da-prepare/actions/msm/README.md @@ -0,0 +1,29 @@ +## Multi-Site Manager (MSM) +MSM enables base/satellite site relationships where satellite sites inherit content from a base site and can optionally override individual pages. + +### Configuration +MSM is configured via an `msm` sheet in the org-level DA config (`/config#/{org}/`). Each row defines a base-satellite relationship: + +| base | satellite | title | +| :--- | :--- | :--- | +| `my-base` | | Base Site | +| `my-base` | `satellite-1` | Satellite Site 1 | +| `my-base` | `satellite-2` | Satellite Site 2 | + +- The `base` column identifies the base site repo name. +- Rows with an empty `satellite` column define the base site entry and its display title. +- Rows with a `satellite` value define satellite sites that inherit from that base. + +### Features + +**Base site view** — when editing a page on the base site, the MSM panel shows all satellites split into inherited and custom (override) lists. Available actions: +- **Preview / Publish** — push the base page to inherited satellite sites via AEM. +- **Cancel inheritance** — copy the base page to a satellite, creating a local override. +- **Sync to satellite** — push updates to custom satellites via merge or full override. +- **Resume inheritance** — delete the satellite override so it falls back to the base. Automatically previews/publishes the page from the base based on the satellite's prior AEM status. + +Custom satellites always show an "Open in editor" link so base authors can inspect overrides. + +**Satellite site view** — when editing a page on a satellite site, the MSM panel shows the base site and offers: +- **Sync from Base** — pull latest base content via merge or full override. +- **Resume inheritance** — delete the local override. Automatically previews/publishes from the base based on prior AEM status. \ No newline at end of file diff --git a/blocks/edit/da-prepare/actions/msm/helpers/config.js b/blocks/edit/da-prepare/actions/msm/helpers/config.js new file mode 100644 index 000000000..952bdbd88 --- /dev/null +++ b/blocks/edit/da-prepare/actions/msm/helpers/config.js @@ -0,0 +1,92 @@ +import { DA_ORIGIN } from '../../../../../shared/constants.js'; +import { daFetch, fetchDaConfigs } from '../../../../../shared/utils.js'; + +const configCache = {}; + +async function fetchOrgMsmRows(org) { + const [orgConfig] = await Promise.all(fetchDaConfigs({ org })); + return orgConfig?.msm?.data || []; +} + +function resolveConfig(rows, site) { + const hasBaseCol = rows[0].base !== undefined; + + if (hasBaseCol) { + const baseRows = rows.filter((row) => row.base === site); + const satelliteRows = baseRows.filter((row) => row.satellite); + if (satelliteRows.length) { + const baseEntry = baseRows.find((row) => !row.satellite); + const satellites = satelliteRows.reduce((acc, row) => { + acc[row.satellite] = { label: row.title }; + return acc; + }, {}); + return { role: 'base', baseLabel: baseEntry?.title, satellites }; + } + const satRow = rows.find((row) => row.satellite === site); + if (satRow) { + const baseEntry = rows.find((row) => row.base === satRow.base && !row.satellite); + return { role: 'satellite', base: satRow.base, baseLabel: baseEntry?.title }; + } + return null; + } + + const isSatellite = rows.some((row) => row.satellite === site); + if (isSatellite) return null; + + const satellites = rows.reduce((acc, row) => { + if (row.satellite) acc[row.satellite] = { label: row.title }; + return acc; + }, {}); + return Object.keys(satellites).length ? { role: 'base', satellites } : null; +} + +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; + return config; +} + +export async function getSatellites(org, baseSite) { + const config = await fetchSiteConfig(org, baseSite); + if (!config) return {}; + if (config.role === 'base') return config.satellites; + return {}; +} + +export async function getBaseSite(org, satellite) { + const config = await fetchSiteConfig(org, satellite); + if (!config) return null; + if (config.role === 'satellite') return config.base; + return 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, hasOverride: local }; + }), + ); + return results; +} + +export function clearMsmCache() { + Object.keys(configCache).forEach((key) => { delete configCache[key]; }); +} diff --git a/blocks/edit/da-prepare/actions/msm/helpers/utils.js b/blocks/edit/da-prepare/actions/msm/helpers/utils.js new file mode 100644 index 000000000..a0e6ecef8 --- /dev/null +++ b/blocks/edit/da-prepare/actions/msm/helpers/utils.js @@ -0,0 +1,85 @@ +import { DA_ORIGIN } from '../../../../../shared/constants.js'; +import { daFetch } from '../../../../../shared/utils.js'; +import { getNx } from '../../../../../../scripts/utils.js'; + +const AEM_ADMIN = 'https://admin.hlx.page'; + +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; } + +export async function mergeFromBase(org, base, satellite, pagePath) { + try { + const mergeCopy = mergeCopyFn + || (await import(`${getNx()}/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 = `${window.location.origin}/edit#/${org}/${satellite}${pagePath}`; + return { ok: true, editUrl }; + } catch (e) { + return { error: e.message || 'Merge failed' }; + } +} diff --git a/blocks/edit/da-prepare/actions/msm/msm.css b/blocks/edit/da-prepare/actions/msm/msm.css new file mode 100644 index 000000000..884982561 --- /dev/null +++ b/blocks/edit/da-prepare/actions/msm/msm.css @@ -0,0 +1,420 @@ +:host { + display: flex; + flex-direction: column; + gap: 16px; + width: 500px; + min-height: 280px; + margin: 0 24px 24px; + + p { margin: 0; } +} + +:host(:has(.picker-menu)) { + min-height: 320px; +} + +.loading, +.no-satellites { + font-size: 14px; + font-style: italic; + color: var(--s2-gray-600, #717171); +} + +/* --- Satellite status line --- */ + +.sat-status-line { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: var(--s2-gray-800, #292929); +} + +.sat-status-label { + color: var(--s2-gray-600, #717171); +} + +.sat-status-value { + font-weight: 600; +} + +/* --- Action row (side-by-side dropdowns) --- */ + +.action-row { + display: flex; + align-items: flex-start; + gap: 16px; + + .form-row { + flex: 0 0 calc(50% - 8px); + min-width: 0; + } +} + +/* --- Form row (rollout-inspired) --- */ + +.form-row { + display: flex; + flex-direction: column; + gap: 4px; + + > label { + font-size: var(--s2-body-xs-size, 12px); + display: block; + color: rgb(80 80 80); + margin-bottom: 0; + } +} + +/* --- Picker trigger --- */ + +.picker-wrapper { + position: relative; +} + +.picker-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + height: 32px; + padding: 0 10px; + background: var(--s2-gray-75, #f8f8f8); + font-family: var(--font-family, "Adobe Clean", adobe-clean, "Trebuchet MS", sans-serif); + font-size: 14px; + color: var(--s2-gray-800, #292929); + border: 1px solid var(--s2-gray-300, #d1d1d1); + border-radius: 8px; + cursor: pointer; + box-sizing: border-box; + transition: border-color 0.15s, background-color 0.15s; + + &:hover:not(:disabled) { + border-color: var(--s2-gray-500, #929292); + } + + &.open { + border-color: var(--s2-blue-900, #3b63fb); + } + + &:focus-visible { + outline: 2px solid var(--s2-blue-900, #3b63fb); + outline-offset: 2px; + } + + &:disabled { + background: var(--s2-gray-75, #f3f3f3); + border-color: var(--s2-gray-200, #e1e1e1); + color: var(--s2-gray-400, #b8b8b8); + cursor: default; + } +} + +.picker-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.picker-chevron { + width: 10px; + height: 10px; + flex-shrink: 0; + color: var(--s2-gray-600, #717171); + transition: transform 0.15s; + + .open & { transform: rotate(180deg); } + :disabled & { color: var(--s2-gray-400, #b8b8b8); } +} + +/* --- Picker menu (floating overlay) --- */ + +.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, #e1e1e1); + border-radius: 8px; + box-shadow: 0 4px 16px rgb(0 0 0 / 12%); +} + +.picker-group-header { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500, #929292); + padding: 8px 10px 4px; + user-select: none; + + &: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, #292929); + border-radius: 6px; + cursor: pointer; + user-select: none; + transition: background-color 0.1s; + + &:hover { background: var(--s2-gray-100, #f5f5f5); } + + &.selected { + font-weight: 600; + + .picker-checkmark { visibility: visible; } + } +} + +.picker-checkmark { + width: 12px; + height: 12px; + flex-shrink: 0; + visibility: hidden; + color: var(--s2-gray-800, #292929); +} + +/* --- Two-column grid --- */ + +.satellite-grid { + display: flex; + gap: 16px; + margin-top: 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, #929292); + padding-bottom: 4px; + border-bottom: 1px solid var(--s2-gray-200, #e1e1e1); + margin-bottom: 2px; +} + +/* --- Satellite list --- */ + +.satellite-list { + list-style: none; + padding: 0; + margin: 0; + max-height: 260px; + overflow-y: auto; + + &::-webkit-scrollbar { width: 5px; } + &::-webkit-scrollbar-track { background: transparent; } + &::-webkit-scrollbar-thumb { + background: var(--s2-gray-300, #d1d1d1); + border-radius: 3px; + &:hover { background: var(--s2-gray-500, #999); } + } +} + +.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-100, #f5f5f5); } + + &.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.css tokens) --- */ + +.sat-row 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; + /* checkmark: 2px white strokes */ + 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-900, #3b63fb); + outline-offset: 2px; + } + + &:disabled { opacity: 0.4; cursor: default; } + + &:hover:not(:disabled) { + border-color: var(--s2-gray-700, #505050); + } + + &: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 actions --- */ + +.form-actions { + display: flex; + justify-content: flex-end; +} + +/* --- Confirm dialog --- */ + +.confirm-box { + padding: 12px; + background: #fef9ee; + border: 1px solid #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-900, #1473e6); + 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/blocks/edit/da-prepare/actions/msm/msm.js b/blocks/edit/da-prepare/actions/msm/msm.js new file mode 100644 index 000000000..bb8449bb2 --- /dev/null +++ b/blocks/edit/da-prepare/actions/msm/msm.js @@ -0,0 +1,581 @@ +import { LitElement, html, nothing } from 'da-lit'; +import getSheet from '../../../../shared/sheet.js'; +import { getSatellites, getBaseSite, isPageLocal, checkOverrides } from './helpers/config.js'; +import { + previewSatellite, + publishSatellite, + createOverride, + deleteOverride, + mergeFromBase, + getSatellitePageStatus, +} from './helpers/utils.js'; + +const sheet = await getSheet(import.meta.url.replace('js', 'css')); + +const STATUS = { pending: 'pending', success: 'success', error: 'error' }; +const SYNC_MODE = { override: 'override', merge: 'merge' }; + +const ACTION_SCOPE = { + preview: 'inherited', + publish: 'inherited', + break: 'inherited', + sync: 'custom', + reset: 'custom', +}; + +class DaMsm extends LitElement { + static properties = { + details: { attribute: false }, + _satellites: { state: true }, + _selected: { state: true }, + _loading: { state: true }, + _busy: { state: true }, + _confirmAction: { state: true }, + _action: { state: true }, + _syncMode: { state: true }, + _role: { state: true }, + _baseSite: { state: true }, + _hasOverride: { state: true }, + _satStatus: { state: true }, + _openPicker: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [sheet]; + this._loading = 'Loading\u2026'; + this._selected = new Set(); + this._action = 'preview'; + this._syncMode = SYNC_MODE.merge; + this._busy = false; + this._openPicker = null; + this._handleOutsidePickerClick = this._handleOutsidePickerClick.bind(this); + this.loadSatellites(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('pointerdown', this._handleOutsidePickerClick); + } + + _handleOutsidePickerClick(e) { + if (!e.composedPath().includes(this)) { + this._openPicker = null; + document.removeEventListener('pointerdown', this._handleOutsidePickerClick); + } + } + + togglePicker(name) { + if (this._openPicker === name) { + this._openPicker = null; + document.removeEventListener('pointerdown', this._handleOutsidePickerClick); + } else { + this._openPicker = name; + document.addEventListener('pointerdown', this._handleOutsidePickerClick); + } + } + + selectPickerOption(name, value, setter) { + setter(value); + this._openPicker = null; + document.removeEventListener('pointerdown', this._handleOutsidePickerClick); + } + + async loadSatellites() { + const { org, site, path } = this.details; + this._loading = 'Loading configuration\u2026'; + + const satellites = await getSatellites(org, site); + + if (satellites && Object.keys(satellites).length) { + this._role = 'base'; + this._loading = 'Checking overrides\u2026'; + const results = await checkOverrides(org, satellites, path); + this._satellites = results.map((sat) => ({ ...sat, status: undefined })); + this._loading = undefined; + return; + } + + const baseSite = await getBaseSite(org, site); + if (baseSite) { + this._role = 'satellite'; + this._baseSite = baseSite; + this._action = 'sync-from-base'; + this._hasOverride = await isPageLocal(org, site, path); + this._loading = undefined; + return; + } + + this._satellites = []; + this._loading = undefined; + } + + get _inherited() { + return this._satellites?.filter((s) => !s.hasOverride) || []; + } + + get _custom() { + return this._satellites?.filter((s) => s.hasOverride) || []; + } + + get _targets() { + const scope = ACTION_SCOPE[this._action]; + const pool = scope === 'custom' ? this._custom : this._inherited; + return pool.filter((s) => this._selected.has(s.site)); + } + + get _canApply() { + return !this._busy && this._targets.length > 0; + } + + handleToggle(site) { + const next = new Set(this._selected); + if (next.has(site)) next.delete(site); + else next.add(site); + this._selected = next; + } + + clearStatuses() { + this._satellites = this._satellites?.map((s) => ({ ...s, status: undefined })); + } + + updateSatStatus(site, status) { + this._satellites = this._satellites.map( + (s) => (s.site === site ? { ...s, status } : s), + ); + } + + async apply() { + if (this._role === 'satellite') { + this.applySatelliteAction(); + return; + } + + if (!this._canApply) return; + + if (this._action === 'reset') { + const names = this._targets.map((s) => s.label).join(', '); + this._confirmAction = { message: `Resume inheritance for ${names}? This deletes local overrides.` }; + return; + } + + await this.runAction(this._action); + } + + cancelConfirm() { + this._confirmAction = undefined; + } + + async doConfirmedAction() { + const { confirmedAction } = this._confirmAction || {}; + this._confirmAction = undefined; + if (confirmedAction === 'resume-inheritance') { + await this.runSatelliteAction('resume-inheritance'); + } else { + await this.runAction('reset'); + } + } + + async runAction(action) { + this._busy = true; + const { org, site, path } = this.details; + const targets = this._targets; + + targets.forEach((s) => this.updateSatStatus(s.site, STATUS.pending)); + + switch (action) { + case 'preview': + case 'publish': { + const fn = action === 'publish' ? publishSatellite : previewSatellite; + await Promise.allSettled(targets.map(async (sat) => { + const result = await fn(org, sat.site, path); + this.updateSatStatus(sat.site, result.error ? STATUS.error : STATUS.success); + })); + break; + } + + case 'break': + await Promise.allSettled(targets.map(async (sat) => { + const result = await createOverride(org, site, sat.site, path); + if (result.error) { + this.updateSatStatus(sat.site, STATUS.error); + } else { + this._satellites = this._satellites.map( + (s) => (s.site === sat.site + ? { ...s, hasOverride: true, status: STATUS.success } + : s), + ); + } + })); + break; + + case 'sync': + if (this._syncMode === SYNC_MODE.merge) { + await Promise.allSettled(targets.map(async (sat) => { + const result = await mergeFromBase(org, site, sat.site, path); + if (result.error) { + this.updateSatStatus(sat.site, STATUS.error); + } else { + this._satellites = this._satellites.map( + (s) => (s.site === sat.site + ? { ...s, editUrl: result.editUrl, status: STATUS.success } + : s), + ); + } + })); + } else { + await Promise.allSettled(targets.map(async (sat) => { + const result = await createOverride(org, site, sat.site, path); + this.updateSatStatus(sat.site, result.error ? STATUS.error : STATUS.success); + })); + } + break; + + case 'reset': + await Promise.allSettled(targets.map(async (sat) => { + const pageStatus = await getSatellitePageStatus(org, sat.site, path); + const result = await deleteOverride(org, sat.site, path); + if (result.error) { + this.updateSatStatus(sat.site, STATUS.error); + } else { + if (pageStatus.live) { + await previewSatellite(org, sat.site, path); + await publishSatellite(org, sat.site, path); + } else if (pageStatus.preview) { + await previewSatellite(org, sat.site, path); + } + this._satellites = this._satellites.map( + (s) => (s.site === sat.site + ? { ...s, hasOverride: false, status: STATUS.success } + : s), + ); + } + })); + break; + + default: + break; + } + + this._selected = new Set(); + this._busy = false; + } + + applySatelliteAction() { + if (this._busy) return; + + if (this._action === 'resume-inheritance') { + this._confirmAction = { + message: 'Resume inheritance? This deletes the local override.', + confirmedAction: 'resume-inheritance', + }; + return; + } + + this.runSatelliteAction(this._action); + } + + async runSatelliteAction(action) { + this._busy = true; + this._satStatus = STATUS.pending; + const { org, site, path } = this.details; + + try { + let result; + if (action === 'sync-from-base') { + result = this._syncMode === SYNC_MODE.merge + ? await mergeFromBase(org, this._baseSite, site, path) + : await createOverride(org, this._baseSite, site, path); + } else if (action === 'resume-inheritance') { + const pageStatus = await getSatellitePageStatus(org, site, path); + result = await deleteOverride(org, site, path); + if (!result?.error) { + if (pageStatus.live) { + await previewSatellite(org, site, path); + await publishSatellite(org, site, path); + } else if (pageStatus.preview) { + await previewSatellite(org, site, path); + } + } + } + + if (result?.error) { + this._satStatus = STATUS.error; + } else { + this._satStatus = STATUS.success; + if (action === 'resume-inheritance') { + this._hasOverride = false; + } else { + this._hasOverride = true; + } + } + } catch { + this._satStatus = STATUS.error; + } + + this._busy = false; + } + + renderStatusIcon(sat) { + if (!sat.status) return nothing; + if (sat.status === STATUS.pending) { + return html` + + `; + } + if (sat.status === STATUS.success) { + return html` + + `; + } + return html` + + `; + } + + renderSatellite(sat) { + const scope = ACTION_SCOPE[this._action]; + const outOfScope = (scope === 'inherited') === sat.hasOverride; + + return html` +
  • + + ${this.renderStatusIcon(sat)} + ${sat.hasOverride ? html` + + + ` : nothing} +
  • `; + } + + renderConfirm() { + if (!this._confirmAction) return nothing; + return html` +
    +

    ${this._confirmAction.message}

    +
    + + +
    +
    `; + } + + 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` + ` : nothing} +
    +
    `; + } + + renderActionControls() { + const actionOptions = [ + { + 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 syncOptions = [ + { value: 'merge', label: 'Merge' }, + { value: 'override', label: 'Override' }, + ]; + + return html` +
    + ${this.renderPicker( + 'action', + 'Action', + this._action, + actionOptions, + (v) => { this._action = v; this.clearStatuses(); }, + )} + ${this._action === 'sync' ? this.renderPicker( + 'syncMode', + 'Sync mode', + this._syncMode, + syncOptions, + (v) => { this._syncMode = v; }, + ) : nothing} +
    `; + } + + renderList() { + const inherited = this._inherited; + const custom = this._custom; + + return html` +
    + ${inherited.length ? html` +
    +

    Inherited

    + +
    ` : nothing} + ${custom.length ? html` +
    +

    Custom

    + +
    ` : nothing} +
    `; + } + + renderSatelliteStatusIcon() { + if (!this._satStatus) return nothing; + if (this._satStatus === STATUS.pending) { + return html` + + `; + } + if (this._satStatus === STATUS.success) { + return html` + + `; + } + return html` + + `; + } + + renderSatelliteView() { + const canResume = this._action === 'resume-inheritance' && !this._hasOverride; + + const satActionOptions = [ + { value: 'sync-from-base', label: 'Sync from Base' }, + { value: 'resume-inheritance', label: 'Resume inheritance' }, + ]; + + const syncOptions = [ + { value: 'merge', label: 'Merge' }, + { value: 'override', label: 'Override' }, + ]; + + return html` +
    + Base site: + ${this._baseSite} + ${this.renderSatelliteStatusIcon()} +
    +
    + ${this.renderPicker( + 'action', + 'Action', + this._action, + satActionOptions, + (v) => { this._action = v; this._satStatus = undefined; }, + )} + ${this._action === 'sync-from-base' ? this.renderPicker( + 'syncMode', + 'Sync mode', + this._syncMode, + syncOptions, + (v) => { this._syncMode = v; }, + ) : nothing} +
    + ${this.renderConfirm()} +
    + this.apply()} + ?disabled=${this._busy || canResume}>Apply +
    `; + } + + render() { + if (this._loading) { + return html`

    ${this._loading}

    `; + } + + if (this._role === 'satellite') { + return this.renderSatelliteView(); + } + + if (!this._satellites?.length) { + return html`

    No satellite sites configured.

    `; + } + + return html` + ${this.renderActionControls()} + ${this.renderList()} + ${this.renderConfirm()} +
    + this.apply()} + ?disabled=${!this._canApply}>Apply +
    `; + } +} + +customElements.define('da-msm', DaMsm); + +export default function render(details) { + const cmp = document.createElement('da-msm'); + cmp.details = details; + return cmp; +} diff --git a/blocks/edit/da-prepare/da-prepare.js b/blocks/edit/da-prepare/da-prepare.js index ab3035b69..57ec14a67 100644 --- a/blocks/edit/da-prepare/da-prepare.js +++ b/blocks/edit/da-prepare/da-prepare.js @@ -27,6 +27,12 @@ const OOTB_ACTIONS = [ icon: '/blocks/edit/img/S2_Icon_Target_20_N.svg#S2_Icon_Target', optional: true, }, + { + title: 'Multi-site Manager', + render: async (details) => (await import('./actions/msm/msm.js')).default(details), + icon: '/blocks/edit/img/S2_Icon_GlobeGrid_20_N.svg#S2_Icon_GlobeGrid', + optional: true, + }, ]; export default class DaPrepare extends LitElement { diff --git a/test/unit/blocks/edit/da-prepare/actions/msm/config.test.js b/test/unit/blocks/edit/da-prepare/actions/msm/config.test.js new file mode 100644 index 000000000..243adb77a --- /dev/null +++ b/test/unit/blocks/edit/da-prepare/actions/msm/config.test.js @@ -0,0 +1,227 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../../../scripts/utils.js'; +import { + getSatellites, + getBaseSite, + isPageLocal, + checkOverrides, + clearMsmCache, +} from '../../../../../../../blocks/edit/da-prepare/actions/msm/helpers/config.js'; + +const ORG_CONFIG = { + msm: { + data: [ + { base: 'mccs', satellite: '', title: 'MCCS Global' }, + { base: 'mccs', satellite: 'san-diego-mccs', title: 'San Diego MCCS' }, + { base: 'mccs', satellite: 'camp-pendleton-mccs', title: 'Camp Pendleton MCCS' }, + { base: 'mccs', satellite: 'miramar-mccs', title: 'Miramar MCCS' }, + ], + }, +}; + +const SIMPLE_ORG_CONFIG = { + msm: { + data: [ + { satellite: 'san-diego-mccs', title: 'San Diego MCCS' }, + { satellite: 'camp-pendleton-mccs', title: 'Camp Pendleton MCCS' }, + { satellite: 'miramar-mccs', title: 'Miramar MCCS' }, + ], + }, +}; + +describe('MSM config', () => { + let savedFetch; + let savedLocalStorage; + + beforeEach(() => { + savedFetch = window.fetch; + savedLocalStorage = window.localStorage.getItem('nx-ims'); + window.localStorage.removeItem('nx-ims'); + // see msm.test.js — the org config is fetched through the nx2 config API + setNx('/test/fixtures/nx', { hostname: 'example.com' }); + clearMsmCache(); + }); + + afterEach(() => { + window.fetch = savedFetch; + if (savedLocalStorage) { + window.localStorage.setItem('nx-ims', savedLocalStorage); + } else { + window.localStorage.removeItem('nx-ims'); + } + }); + + describe('getSatellites', () => { + it('returns satellites from a base site config', async () => { + window.fetch = () => Promise.resolve( + new Response(JSON.stringify(ORG_CONFIG), { status: 200 }), + ); + + const satellites = await getSatellites('org-base', 'mccs'); + expect(Object.keys(satellites)).to.have.length(3); + expect(satellites['san-diego-mccs'].label).to.equal('San Diego MCCS'); + expect(satellites['camp-pendleton-mccs'].label).to.equal('Camp Pendleton MCCS'); + expect(satellites['miramar-mccs'].label).to.equal('Miramar MCCS'); + }); + + it('returns empty object when fetch fails', async () => { + window.fetch = () => Promise.resolve(new Response('', { status: 404 })); + + const satellites = await getSatellites('org-fail', 'mccs'); + expect(satellites).to.deep.equal({}); + }); + + it('returns empty object when called on a satellite site', async () => { + window.fetch = () => Promise.resolve( + new Response(JSON.stringify(ORG_CONFIG), { status: 200 }), + ); + + const satellites = await getSatellites('org-sat', 'san-diego-mccs'); + expect(satellites).to.deep.equal({}); + }); + + it('returns empty object when data is empty', async () => { + window.fetch = () => Promise.resolve( + new Response(JSON.stringify({ msm: { data: [] } }), { status: 200 }), + ); + + const satellites = await getSatellites('org-empty', 'mccs'); + expect(satellites).to.deep.equal({}); + }); + + it('caches config across calls', async () => { + let callCount = 0; + window.fetch = () => { + callCount += 1; + return Promise.resolve( + new Response(JSON.stringify(ORG_CONFIG), { status: 200 }), + ); + }; + + await getSatellites('org-cache', 'mccs'); + await getSatellites('org-cache', 'mccs'); + expect(callCount).to.equal(1); + }); + + it('returns satellites without base column when site is not a satellite', async () => { + window.fetch = () => Promise.resolve( + new Response(JSON.stringify(SIMPLE_ORG_CONFIG), { status: 200 }), + ); + + const satellites = await getSatellites('org-simple', 'mccs'); + expect(Object.keys(satellites)).to.have.length(3); + expect(satellites['san-diego-mccs'].label).to.equal('San Diego MCCS'); + }); + + it('returns empty object without base column when site is a satellite', async () => { + window.fetch = () => Promise.resolve( + new Response(JSON.stringify(SIMPLE_ORG_CONFIG), { status: 200 }), + ); + + const satellites = await getSatellites('org-simple-sat', 'san-diego-mccs'); + expect(satellites).to.deep.equal({}); + }); + }); + + describe('getBaseSite', () => { + it('returns base site from a satellite site', async () => { + window.fetch = () => Promise.resolve( + new Response(JSON.stringify(ORG_CONFIG), { status: 200 }), + ); + + const base = await getBaseSite('org-getbase', 'san-diego-mccs'); + expect(base).to.equal('mccs'); + }); + + it('returns null when called on a base site', async () => { + window.fetch = () => Promise.resolve( + new Response(JSON.stringify(ORG_CONFIG), { status: 200 }), + ); + + const base = await getBaseSite('org-getbase-null', 'mccs'); + expect(base).to.be.null; + }); + + it('returns null when fetch fails', async () => { + window.fetch = () => Promise.resolve(new Response('', { status: 404 })); + + const base = await getBaseSite('org-getbase-fail', 'unknown-site'); + expect(base).to.be.null; + }); + + it('returns null when data is empty', async () => { + window.fetch = () => Promise.resolve( + new Response(JSON.stringify({ msm: { data: [] } }), { status: 200 }), + ); + + const base = await getBaseSite('org-getbase-empty', 'san-diego-mccs'); + expect(base).to.be.null; + }); + }); + + describe('isPageLocal', () => { + it('returns true when HEAD returns 200', async () => { + window.fetch = (url, opts) => { + expect(opts.method).to.equal('HEAD'); + return Promise.resolve(new Response('', { status: 200 })); + }; + + const result = await isPageLocal('org', 'san-diego-mccs', '/about'); + expect(result).to.be.true; + }); + + it('returns false when HEAD returns 404', async () => { + window.fetch = () => Promise.resolve(new Response('', { status: 404 })); + + const result = await isPageLocal('org', 'san-diego-mccs', '/about'); + expect(result).to.be.false; + }); + }); + + describe('checkOverrides', () => { + it('returns override status for all satellites', async () => { + window.fetch = (url) => { + if (url.includes('san-diego-mccs')) { + return Promise.resolve(new Response('', { status: 200 })); + } + return Promise.resolve(new Response('', { status: 404 })); + }; + + const satellites = { + 'san-diego-mccs': { label: 'San Diego MCCS' }, + 'camp-pendleton-mccs': { label: 'Camp Pendleton MCCS' }, + }; + + const results = await checkOverrides('org', satellites, '/about'); + expect(results).to.have.length(2); + + const sdResult = results.find((r) => r.site === 'san-diego-mccs'); + expect(sdResult.hasOverride).to.be.true; + expect(sdResult.label).to.equal('San Diego MCCS'); + + const cpResult = results.find((r) => r.site === 'camp-pendleton-mccs'); + expect(cpResult.hasOverride).to.be.false; + }); + + it('handles empty satellites', async () => { + const results = await checkOverrides('org', {}, '/about'); + expect(results).to.deep.equal([]); + }); + }); + + describe('clearMsmCache', () => { + it('clears site-level cache so role is re-resolved', async () => { + window.fetch = () => Promise.resolve( + new Response(JSON.stringify(ORG_CONFIG), { status: 200 }), + ); + + const satellites = await getSatellites('org-clear', 'mccs'); + expect(Object.keys(satellites)).to.have.length(3); + + clearMsmCache(); + + const base = await getBaseSite('org-clear', 'san-diego-mccs'); + expect(base).to.equal('mccs'); + }); + }); +}); diff --git a/test/unit/blocks/edit/da-prepare/actions/msm/msm.test.js b/test/unit/blocks/edit/da-prepare/actions/msm/msm.test.js new file mode 100644 index 000000000..41cb33ce7 --- /dev/null +++ b/test/unit/blocks/edit/da-prepare/actions/msm/msm.test.js @@ -0,0 +1,699 @@ +/* eslint-disable no-underscore-dangle */ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../../../scripts/utils.js'; +import { setMergeCopy } from '../../../../../../../blocks/edit/da-prepare/actions/msm/helpers/utils.js'; + +const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); }); +const waitForLoad = () => new Promise((resolve) => { setTimeout(resolve, 100); }); + +const BASE_CONFIG = { + msm: { + data: [ + { base: 'mccs', satellite: '', title: 'MCCS Global' }, + { base: 'mccs', satellite: 'san-diego', title: 'San Diego' }, + { base: 'mccs', satellite: 'pendleton', title: 'Camp Pendleton' }, + ], + }, +}; + +function createFetchMock({ orgConfigs = {}, overrideSites = [], aemStatus } = {}) { + return async (url, opts = {}) => { + if (url.endsWith('.css')) { + return new Response('', { status: 200, headers: { 'Content-Type': 'text/css' } }); + } + if (url.includes('/config/')) { + for (const [org, config] of Object.entries(orgConfigs)) { + if (url.includes(`/config/${org}`)) { + return new Response(JSON.stringify(config), { status: 200 }); + } + } + return new Response(JSON.stringify({}), { status: 200 }); + } + if (opts.method === 'HEAD') { + const hasOverride = overrideSites.some((site) => url.includes(`/${site}/`)); + return new Response('', { status: hasOverride ? 200 : 404 }); + } + if (url.includes('admin.hlx.page/status/')) { + const body = aemStatus || { preview: { status: 200 }, live: { status: 200 } }; + return new Response(JSON.stringify(body), { status: 200 }); + } + if (url.includes('admin.hlx.page/preview/') || url.includes('admin.hlx.page/live/')) { + if (opts.method === 'POST') { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + } + if (opts.method === 'DELETE') { + return new Response(null, { status: 204 }); + } + if (opts.method === 'PUT') { + return new Response('', { status: 201 }); + } + if (url.includes('/source/')) { + return new Response('

    Base content

    ', { status: 200 }); + } + return new Response('{}', { status: 200 }); + }; +} + +describe('DaMsm component', () => { + let savedFetch; + let savedLocalStorage; + let el; + + before(async () => { + savedFetch = window.fetch; + savedLocalStorage = window.localStorage.getItem('nx-ims'); + window.localStorage.removeItem('nx-ims'); + + // fetchDaConfigs now reaches the org config through the nx2 config API, so + // the suite has to point nx at the test fixtures the way every other + // da-prepare test does; without it getNx2Api() tries to load the real one. + setNx('/test/fixtures/nx', { hostname: 'example.com' }); + + window.fetch = async (url) => { + if (url.endsWith('.css')) { + return new Response('', { status: 200, headers: { 'Content-Type': 'text/css' } }); + } + return new Response('{}', { status: 200 }); + }; + + await import('../../../../../../../blocks/edit/da-prepare/actions/msm/msm.js'); + }); + + after(() => { + window.fetch = savedFetch; + if (savedLocalStorage) { + window.localStorage.setItem('nx-ims', savedLocalStorage); + } else { + window.localStorage.removeItem('nx-ims'); + } + }); + + afterEach(() => { + if (el && el.parentElement) el.remove(); + el = null; + setMergeCopy(null); + }); + + async function fixture(details, fetchMock) { + window.fetch = fetchMock; + el = document.createElement('da-msm'); + el.details = details; + document.body.appendChild(el); + await waitForLoad(); + await nextFrame(); + return el; + } + + function makeSatellites(overrides = []) { + return [ + { site: 'san-diego', label: 'San Diego', hasOverride: overrides.includes('san-diego'), status: undefined }, + { site: 'pendleton', label: 'Camp Pendleton', hasOverride: overrides.includes('pendleton'), status: undefined }, + ]; + } + + async function fixtureWithState(fetchMock, stateOverrides = {}) { + window.fetch = fetchMock; + el = document.createElement('da-msm'); + el.details = stateOverrides.details || { org: 'test', site: 'mccs', path: '/about' }; + el.loadSatellites = () => {}; + document.body.appendChild(el); + el._loading = undefined; + el._role = stateOverrides.role || 'base'; + el._satellites = stateOverrides.satellites || makeSatellites(); + el._selected = stateOverrides.selected || new Set(); + el._action = stateOverrides.action || 'preview'; + if (stateOverrides.baseSite) el._baseSite = stateOverrides.baseSite; + if (stateOverrides.hasOverride !== undefined) el._hasOverride = stateOverrides.hasOverride; + el.requestUpdate(); + await nextFrame(); + await nextFrame(); + return el; + } + + it('is defined as a custom element', () => { + expect(customElements.get('da-msm')).to.exist; + }); + + describe('loading', () => { + it('resolves to base role with satellite list', async () => { + const mock = createFetchMock({ + orgConfigs: { 'msm-load': BASE_CONFIG }, + overrideSites: ['san-diego'], + }); + await fixture({ org: 'msm-load', site: 'mccs', path: '/about' }, mock); + + expect(el._loading).to.be.undefined; + expect(el._role).to.equal('base'); + expect(el._satellites).to.have.length(2); + }); + + it('shows no-satellites message when config is empty', async () => { + const mock = createFetchMock({ orgConfigs: { 'msm-empty': {} } }); + await fixture({ org: 'msm-empty', site: 'mccs', path: '/about' }, mock); + + const msg = el.shadowRoot.querySelector('.no-satellites'); + expect(msg).to.exist; + }); + + it('resolves to satellite role when site is a satellite', async () => { + const mock = createFetchMock({ orgConfigs: { 'msm-satload': BASE_CONFIG } }); + await fixture({ org: 'msm-satload', site: 'san-diego', path: '/about' }, mock); + + expect(el._role).to.equal('satellite'); + expect(el._baseSite).to.equal('mccs'); + }); + }); + + describe('rendering — base view', () => { + it('renders inherited and custom columns', async () => { + const mock = createFetchMock({ + orgConfigs: { 'msm-cols': BASE_CONFIG }, + overrideSites: ['san-diego'], + }); + await fixture({ org: 'msm-cols', site: 'mccs', path: '/about' }, mock); + await nextFrame(); + + const columns = el.shadowRoot.querySelectorAll('.satellite-column'); + expect(columns.length).to.equal(2); + + const headings = [...columns].map((c) => c.querySelector('.column-heading').textContent); + expect(headings).to.include('Inherited'); + expect(headings).to.include('Custom'); + }); + + it('shows open-in-editor link only for custom sites', async () => { + const mock = createFetchMock({ + orgConfigs: { 'msm-link': BASE_CONFIG }, + overrideSites: ['san-diego'], + }); + await fixture({ org: 'msm-link', site: 'mccs', path: '/about' }, mock); + await nextFrame(); + + const links = el.shadowRoot.querySelectorAll('.icon-btn'); + expect(links.length).to.equal(1); + expect(links[0].getAttribute('href')).to.include('san-diego'); + }); + + it('dims out-of-scope satellites', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock, { + satellites: makeSatellites(['san-diego']), + action: 'preview', + }); + + const outOfScope = el.shadowRoot.querySelectorAll('.out-of-scope'); + expect(outOfScope.length).to.be.greaterThan(0); + }); + + it('renders action picker', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock); + + const picker = el.shadowRoot.querySelector('.picker-trigger'); + expect(picker).to.exist; + }); + }); + + describe('rendering — satellite view', () => { + it('shows base site name and action pickers', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock, { + role: 'satellite', + baseSite: 'mccs', + hasOverride: false, + details: { org: 'test', site: 'san-diego', path: '/about' }, + }); + + const statusLine = el.shadowRoot.querySelector('.sat-status-line'); + expect(statusLine).to.exist; + expect(statusLine.textContent).to.include('mccs'); + }); + + it('disables apply when resume-inheritance has no override', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock, { + role: 'satellite', + baseSite: 'mccs', + hasOverride: false, + action: 'resume-inheritance', + details: { org: 'test', site: 'san-diego', path: '/about' }, + }); + + const btn = el.shadowRoot.querySelector('sl-button'); + expect(btn.hasAttribute('disabled')).to.be.true; + }); + }); + + describe('selection', () => { + it('toggles satellite selection on and off', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock); + + expect(el._selected.size).to.equal(0); + el.handleToggle('pendleton'); + expect(el._selected.has('pendleton')).to.be.true; + el.handleToggle('pendleton'); + expect(el._selected.has('pendleton')).to.be.false; + }); + + it('_canApply is false with no selection', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock); + + expect(el._canApply).to.be.false; + }); + + it('_canApply is true with in-scope selection', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock, { action: 'preview' }); + + el.handleToggle('pendleton'); + expect(el._canApply).to.be.true; + }); + + it('_canApply is false when selection is out of scope', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock, { + satellites: makeSatellites(['san-diego']), + action: 'preview', + }); + + el.handleToggle('san-diego'); + expect(el._canApply).to.be.false; + }); + }); + + describe('preview action', () => { + it('previews selected satellites and sets success', async () => { + const calls = []; + const base = createFetchMock({}); + const mock = async (url, opts) => { + calls.push({ url, method: opts?.method }); + return base(url, opts); + }; + await fixtureWithState(mock, { action: 'preview', selected: new Set(['pendleton']) }); + + await el.runAction('preview'); + + expect(calls.some((c) => c.url.includes('/preview/') && c.url.includes('pendleton'))).to.be.true; + const sat = el._satellites.find((s) => s.site === 'pendleton'); + expect(sat.status).to.equal('success'); + }); + + it('sets error status on failure', async () => { + const base = createFetchMock({}); + const mock = async (url, opts) => { + if (url.includes('admin.hlx.page/preview/')) { + return new Response('', { status: 500 }); + } + return base(url, opts); + }; + await fixtureWithState(mock, { action: 'preview', selected: new Set(['pendleton']) }); + + await el.runAction('preview'); + + const sat = el._satellites.find((s) => s.site === 'pendleton'); + expect(sat.status).to.equal('error'); + }); + }); + + describe('publish action', () => { + it('publishes selected satellites', async () => { + const calls = []; + const base = createFetchMock({}); + const mock = async (url, opts) => { + calls.push({ url, method: opts?.method }); + return base(url, opts); + }; + await fixtureWithState(mock, { action: 'publish', selected: new Set(['pendleton']) }); + + await el.runAction('publish'); + + expect(calls.some((c) => c.url.includes('/live/') && c.url.includes('pendleton'))).to.be.true; + }); + }); + + describe('cancel inheritance (break) action', () => { + it('creates override and moves satellite to custom', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock, { action: 'break', selected: new Set(['pendleton']) }); + + await el.runAction('break'); + + const sat = el._satellites.find((s) => s.site === 'pendleton'); + expect(sat.hasOverride).to.be.true; + expect(sat.status).to.equal('success'); + }); + }); + + describe('sync to satellite action', () => { + it('creates override in override mode', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock, { + satellites: makeSatellites(['san-diego']), + action: 'sync', + selected: new Set(['san-diego']), + }); + el._syncMode = 'override'; + + await el.runAction('sync'); + + const sat = el._satellites.find((s) => s.site === 'san-diego'); + expect(sat.status).to.equal('success'); + }); + + it('merges from base in merge mode', async () => { + let mergeCalled = false; + setMergeCopy(async () => { + mergeCalled = true; + return { ok: true }; + }); + + const mock = createFetchMock({}); + await fixtureWithState(mock, { + satellites: makeSatellites(['san-diego']), + action: 'sync', + selected: new Set(['san-diego']), + }); + el._syncMode = 'merge'; + + await el.runAction('sync'); + + expect(mergeCalled).to.be.true; + const sat = el._satellites.find((s) => s.site === 'san-diego'); + expect(sat.status).to.equal('success'); + }); + }); + + describe('resume inheritance (reset) action', () => { + it('shows confirm dialog before executing', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock, { + satellites: makeSatellites(['san-diego']), + action: 'reset', + selected: new Set(['san-diego']), + }); + + await el.apply(); + + expect(el._confirmAction).to.exist; + expect(el._confirmAction.message).to.include('Resume inheritance'); + }); + + it('deletes override and auto-previews/publishes when live', async () => { + const calls = []; + const base = createFetchMock({}); + const mock = async (url, opts) => { + calls.push({ url, method: opts?.method }); + return base(url, opts); + }; + await fixtureWithState(mock, { + satellites: makeSatellites(['san-diego']), + action: 'reset', + selected: new Set(['san-diego']), + }); + + await el.runAction('reset'); + + const sat = el._satellites.find((s) => s.site === 'san-diego'); + expect(sat.hasOverride).to.be.false; + expect(sat.status).to.equal('success'); + expect(calls.some((c) => c.url.includes('/preview/'))).to.be.true; + expect(calls.some((c) => c.url.includes('/live/'))).to.be.true; + }); + + it('only previews when page was not published', async () => { + const calls = []; + const aemStatus = { preview: { status: 200 }, live: { status: 404 } }; + const base = createFetchMock({ aemStatus }); + const mock = async (url, opts) => { + calls.push({ url, method: opts?.method }); + return base(url, opts); + }; + await fixtureWithState(mock, { + satellites: makeSatellites(['san-diego']), + action: 'reset', + selected: new Set(['san-diego']), + }); + + await el.runAction('reset'); + + expect(calls.some((c) => c.url.includes('/preview/'))).to.be.true; + expect(calls.filter((c) => c.url.includes('/live/') && c.method === 'POST').length).to.equal(0); + }); + }); + + describe('post-action behavior', () => { + it('clears selection after action completes', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock, { action: 'preview', selected: new Set(['pendleton']) }); + + expect(el._selected.size).to.equal(1); + await el.runAction('preview'); + expect(el._selected.size).to.equal(0); + }); + + it('clears statuses via clearStatuses()', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock); + + el._satellites = el._satellites.map((s) => ({ ...s, status: 'success' })); + el.clearStatuses(); + + el._satellites.forEach((s) => { + expect(s.status).to.be.undefined; + }); + }); + + it('is not busy after action completes', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock, { action: 'preview', selected: new Set(['pendleton']) }); + + await el.runAction('preview'); + expect(el._busy).to.be.false; + }); + }); + + describe('confirm dialog', () => { + it('cancelConfirm clears the dialog', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock); + + el._confirmAction = { message: 'Test' }; + el.cancelConfirm(); + expect(el._confirmAction).to.be.undefined; + }); + + it('doConfirmedAction runs reset for base view', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock, { + satellites: makeSatellites(['san-diego']), + action: 'reset', + selected: new Set(['san-diego']), + }); + + el._confirmAction = { message: 'Confirm?' }; + await el.doConfirmedAction(); + + expect(el._confirmAction).to.be.undefined; + const sat = el._satellites.find((s) => s.site === 'san-diego'); + expect(sat.hasOverride).to.be.false; + }); + + it('renders confirm box in DOM', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock); + + el._confirmAction = { message: 'Are you sure?' }; + el.requestUpdate(); + await nextFrame(); + await nextFrame(); + + const box = el.shadowRoot.querySelector('.confirm-box'); + expect(box).to.exist; + expect(box.textContent).to.include('Are you sure?'); + }); + }); + + describe('picker', () => { + it('toggles picker open and closed', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock); + + el.togglePicker('action'); + expect(el._openPicker).to.equal('action'); + + el.togglePicker('action'); + expect(el._openPicker).to.be.null; + }); + + it('selectPickerOption sets value and closes picker', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock); + + el._openPicker = 'action'; + let captured; + el.selectPickerOption('action', 'publish', (v) => { captured = v; }); + + expect(captured).to.equal('publish'); + expect(el._openPicker).to.be.null; + }); + + it('closes picker on outside click', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock); + + el.togglePicker('action'); + expect(el._openPicker).to.equal('action'); + + el._handleOutsidePickerClick({ composedPath: () => [] }); + expect(el._openPicker).to.be.null; + }); + + it('does not close picker on inside click', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock); + + el.togglePicker('action'); + el._handleOutsidePickerClick({ composedPath: () => [el] }); + expect(el._openPicker).to.equal('action'); + }); + }); + + describe('satellite view — actions', () => { + function satDetails() { + return { org: 'test', site: 'san-diego', path: '/about' }; + } + + it('sync-from-base with override mode sets hasOverride', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock, { + role: 'satellite', + baseSite: 'mccs', + hasOverride: false, + details: satDetails(), + }); + el._syncMode = 'override'; + el._action = 'sync-from-base'; + + await el.runSatelliteAction('sync-from-base'); + + expect(el._hasOverride).to.be.true; + expect(el._satStatus).to.equal('success'); + }); + + it('sync-from-base with merge mode calls mergeCopy', async () => { + let mergeCalled = false; + setMergeCopy(async () => { + mergeCalled = true; + return { ok: true }; + }); + + const mock = createFetchMock({}); + await fixtureWithState(mock, { + role: 'satellite', + baseSite: 'mccs', + hasOverride: false, + details: satDetails(), + }); + el._syncMode = 'merge'; + el._action = 'sync-from-base'; + + await el.runSatelliteAction('sync-from-base'); + + expect(mergeCalled).to.be.true; + expect(el._hasOverride).to.be.true; + }); + + it('resume-inheritance shows confirm dialog', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock, { + role: 'satellite', + baseSite: 'mccs', + hasOverride: true, + details: satDetails(), + }); + el._action = 'resume-inheritance'; + + el.applySatelliteAction(); + + expect(el._confirmAction).to.exist; + expect(el._confirmAction.confirmedAction).to.equal('resume-inheritance'); + }); + + it('resume-inheritance deletes override and auto-previews/publishes', async () => { + const calls = []; + const base = createFetchMock({}); + const mock = async (url, opts) => { + calls.push({ url, method: opts?.method }); + return base(url, opts); + }; + await fixtureWithState(mock, { + role: 'satellite', + baseSite: 'mccs', + hasOverride: true, + details: satDetails(), + }); + + await el.runSatelliteAction('resume-inheritance'); + + expect(el._hasOverride).to.be.false; + expect(el._satStatus).to.equal('success'); + expect(calls.some((c) => c.url.includes('/preview/'))).to.be.true; + expect(calls.some((c) => c.url.includes('/live/'))).to.be.true; + }); + + it('doConfirmedAction runs resume-inheritance for satellite view', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock, { + role: 'satellite', + baseSite: 'mccs', + hasOverride: true, + details: satDetails(), + }); + + el._confirmAction = { + message: 'Resume?', + confirmedAction: 'resume-inheritance', + }; + await el.doConfirmedAction(); + + expect(el._confirmAction).to.be.undefined; + expect(el._hasOverride).to.be.false; + }); + + it('sets error status when delete fails', async () => { + const base = createFetchMock({}); + const mock = async (url, opts) => { + if (opts?.method === 'DELETE') { + return new Response('', { status: 500 }); + } + return base(url, opts); + }; + await fixtureWithState(mock, { + role: 'satellite', + baseSite: 'mccs', + hasOverride: true, + details: satDetails(), + }); + + await el.runSatelliteAction('resume-inheritance'); + expect(el._satStatus).to.equal('error'); + }); + + it('does not run action when busy', async () => { + const mock = createFetchMock({}); + await fixtureWithState(mock, { + role: 'satellite', + baseSite: 'mccs', + hasOverride: true, + details: satDetails(), + }); + el._busy = true; + + el.applySatelliteAction(); + expect(el._confirmAction).to.be.undefined; + }); + }); +}); diff --git a/test/unit/blocks/edit/da-prepare/actions/msm/utils.test.js b/test/unit/blocks/edit/da-prepare/actions/msm/utils.test.js new file mode 100644 index 000000000..9e2178dd1 --- /dev/null +++ b/test/unit/blocks/edit/da-prepare/actions/msm/utils.test.js @@ -0,0 +1,254 @@ +import { expect } from '@esm-bundle/chai'; +import { + previewSatellite, + publishSatellite, + createOverride, + deleteOverride, + mergeFromBase, + setMergeCopy, + getSatellitePageStatus, +} from '../../../../../../../blocks/edit/da-prepare/actions/msm/helpers/utils.js'; + +describe('MSM utils', () => { + let savedFetch; + let savedLocalStorage; + + beforeEach(() => { + savedFetch = window.fetch; + savedLocalStorage = window.localStorage.getItem('nx-ims'); + window.localStorage.removeItem('nx-ims'); + }); + + afterEach(() => { + window.fetch = savedFetch; + if (savedLocalStorage) { + window.localStorage.setItem('nx-ims', savedLocalStorage); + } else { + window.localStorage.removeItem('nx-ims'); + } + }); + + describe('previewSatellite', () => { + it('POSTs to the correct AEM admin preview URL', async () => { + let capturedUrl; + let capturedMethod; + window.fetch = (url, opts) => { + capturedUrl = url; + capturedMethod = opts.method; + return Promise.resolve( + new Response(JSON.stringify({ preview: { url: 'https://preview.example.com' } }), { status: 200 }), + ); + }; + + const result = await previewSatellite('org', 'san-diego-mccs', '/about'); + expect(capturedUrl).to.equal('https://admin.hlx.page/preview/org/san-diego-mccs/main/about'); + expect(capturedMethod).to.equal('POST'); + expect(result.preview).to.exist; + }); + + it('strips .html from the path', async () => { + let capturedUrl; + window.fetch = (url) => { + capturedUrl = url; + return Promise.resolve( + new Response(JSON.stringify({ preview: {} }), { status: 200 }), + ); + }; + + await previewSatellite('org', 'san-diego-mccs', '/about.html'); + expect(capturedUrl).to.not.include('.html'); + }); + + it('returns error on failure', async () => { + window.fetch = () => Promise.resolve(new Response('', { status: 500 })); + + const result = await previewSatellite('org', 'san-diego-mccs', '/about'); + expect(result.error).to.exist; + }); + }); + + describe('publishSatellite', () => { + it('POSTs to the correct AEM admin live URL', async () => { + let capturedUrl; + window.fetch = (url) => { + capturedUrl = url; + return Promise.resolve( + new Response(JSON.stringify({ live: { url: 'https://live.example.com' } }), { status: 200 }), + ); + }; + + const result = await publishSatellite('org', 'san-diego-mccs', '/about'); + expect(capturedUrl).to.equal('https://admin.hlx.page/live/org/san-diego-mccs/main/about'); + expect(result.live).to.exist; + }); + + it('returns error on failure', async () => { + window.fetch = () => Promise.resolve(new Response('', { status: 403 })); + + const result = await publishSatellite('org', 'san-diego-mccs', '/about'); + expect(result.error).to.exist; + }); + }); + + describe('createOverride', () => { + it('fetches base content and writes to satellite', async () => { + const calls = []; + window.fetch = (url, opts = {}) => { + calls.push({ url, method: opts.method || 'GET' }); + if (url.includes('/mccs/')) { + return Promise.resolve(new Response('

    Base content

    ', { status: 200 })); + } + return Promise.resolve(new Response('', { status: 201 })); + }; + + const result = await createOverride('org', 'mccs', 'san-diego-mccs', '/about'); + expect(result.ok).to.be.true; + + const getCall = calls.find((c) => c.url.includes('/mccs/about')); + expect(getCall).to.exist; + + const putCall = calls.find((c) => c.method === 'PUT'); + expect(putCall).to.exist; + expect(putCall.url).to.include('/san-diego-mccs/about'); + }); + + it('returns error when base fetch fails', async () => { + window.fetch = () => Promise.resolve(new Response('', { status: 404 })); + + const result = await createOverride('org', 'mccs', 'san-diego-mccs', '/about'); + expect(result.error).to.include('base content'); + }); + + it('returns error when satellite write fails', async () => { + let callCount = 0; + window.fetch = () => { + callCount += 1; + if (callCount === 1) { + return Promise.resolve(new Response('

    Content

    ', { status: 200 })); + } + return Promise.resolve(new Response('', { status: 500 })); + }; + + const result = await createOverride('org', 'mccs', 'san-diego-mccs', '/about'); + expect(result.error).to.include('create override'); + }); + }); + + describe('getSatellitePageStatus', () => { + it('returns preview and live status from AEM admin', async () => { + let capturedUrl; + window.fetch = (url) => { + capturedUrl = url; + return Promise.resolve( + new Response(JSON.stringify({ + preview: { status: 200 }, + live: { status: 200 }, + }), { status: 200 }), + ); + }; + + const status = await getSatellitePageStatus('org', 'san-diego-mccs', '/about'); + expect(capturedUrl).to.equal('https://admin.hlx.page/status/org/san-diego-mccs/main/about'); + expect(status.preview).to.be.true; + expect(status.live).to.be.true; + }); + + it('returns preview-only when live is 404', async () => { + window.fetch = () => Promise.resolve( + new Response(JSON.stringify({ + preview: { status: 200 }, + live: { status: 404 }, + }), { status: 200 }), + ); + + const status = await getSatellitePageStatus('org', 'san-diego-mccs', '/about'); + expect(status.preview).to.be.true; + expect(status.live).to.be.false; + }); + + it('returns false for both when fetch fails', async () => { + window.fetch = () => Promise.resolve(new Response('', { status: 500 })); + + const status = await getSatellitePageStatus('org', 'san-diego-mccs', '/about'); + expect(status.preview).to.be.false; + expect(status.live).to.be.false; + }); + + it('strips .html from the path', async () => { + let capturedUrl; + window.fetch = (url) => { + capturedUrl = url; + return Promise.resolve( + new Response(JSON.stringify({ + preview: { status: 404 }, + live: { status: 404 }, + }), { status: 200 }), + ); + }; + + await getSatellitePageStatus('org', 'san-diego-mccs', '/about.html'); + expect(capturedUrl).to.not.include('.html'); + }); + }); + + describe('deleteOverride', () => { + it('DELETEs the satellite page', async () => { + let capturedUrl; + let capturedMethod; + window.fetch = (url, opts) => { + capturedUrl = url; + capturedMethod = opts.method; + return Promise.resolve(new Response(null, { status: 204 })); + }; + + const result = await deleteOverride('org', 'san-diego-mccs', '/about'); + expect(result.ok).to.be.true; + expect(capturedUrl).to.include('/san-diego-mccs/about.html'); + expect(capturedMethod).to.equal('DELETE'); + }); + + it('returns error on failure', async () => { + window.fetch = () => Promise.resolve(new Response('', { status: 500 })); + + const result = await deleteOverride('org', 'san-diego-mccs', '/about'); + expect(result.error).to.include('delete override'); + }); + }); + + describe('mergeFromBase', () => { + afterEach(() => { + setMergeCopy(null); + }); + + it('calls mergeCopy with correct url object and returns editUrl', async () => { + let capturedUrl; + let capturedTitle; + setMergeCopy(async (url, title) => { + capturedUrl = url; + capturedTitle = title; + return { ok: true }; + }); + + const result = await mergeFromBase('org', 'mccs', 'san-diego-mccs', '/about'); + expect(result.ok).to.be.true; + expect(result.editUrl).to.include('/edit#/org/san-diego-mccs/about'); + expect(capturedUrl.source).to.equal('/org/mccs/about.html'); + expect(capturedUrl.destination).to.equal('/org/san-diego-mccs/about.html'); + expect(capturedTitle).to.equal('MSM Merge'); + }); + + it('returns error when mergeCopy returns not ok', async () => { + setMergeCopy(async () => ({ ok: false })); + + const result = await mergeFromBase('org', 'mccs', 'san-diego-mccs', '/about'); + expect(result.error).to.equal('Merge failed'); + }); + + it('returns error when mergeCopy throws', async () => { + setMergeCopy(async () => { throw new Error('Network error'); }); + + const result = await mergeFromBase('org', 'mccs', 'san-diego-mccs', '/about'); + expect(result.error).to.equal('Network error'); + }); + }); +});