diff --git a/blocks/header/categories.js b/blocks/header/categories.js new file mode 100644 index 00000000..77e3ec67 --- /dev/null +++ b/blocks/header/categories.js @@ -0,0 +1,40 @@ +export function slugify(label) { + return String(label) + .toLowerCase() + .replace(/&/g, ' ') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function pathFromHref(href) { + try { + return new URL(href, 'https://tools.aem.live/').pathname; + } catch { + return href; + } +} + +export function parseCategories(html) { + if (!html) return []; + const doc = new window.DOMParser().parseFromString(html, 'text/html'); + const topUl = doc.querySelector('ul'); + if (!topUl) return []; + + const categories = []; + [...topUl.children].forEach((li) => { + const sub = li.querySelector(':scope > ul'); + if (!sub) return; + const labelText = [...li.childNodes] + .filter((n) => n.nodeType === 3 || (n.nodeType === 1 && n.tagName !== 'UL')) + .map((n) => n.textContent) + .join(' ') + .trim(); + if (!labelText) return; + const tools = [...sub.querySelectorAll('a[href]')].map((a) => ({ + url: pathFromHref(a.getAttribute('href')), + label: a.textContent.trim(), + })); + categories.push({ slug: slugify(labelText), label: labelText, tools }); + }); + return categories; +} diff --git a/blocks/header/combobox-filter.js b/blocks/header/combobox-filter.js new file mode 100644 index 00000000..c91882a1 --- /dev/null +++ b/blocks/header/combobox-filter.js @@ -0,0 +1,12 @@ +/** + * Filters a list of string items by a query, matching case-insensitively on substring. + * @param {string} query - The search query. Empty or whitespace-only returns a copy of all items. + * @param {string[]} items - The candidate items. + * @returns {string[]} A new array of the matching items. Never mutates the input. + */ +// eslint-disable-next-line import/prefer-default-export +export function filterItems(query, items) { + const normalized = (query || '').trim().toLowerCase(); + if (!normalized) return [...items]; + return items.filter((item) => String(item).toLowerCase().includes(normalized)); +} diff --git a/blocks/header/combobox.js b/blocks/header/combobox.js new file mode 100644 index 00000000..01255ef2 --- /dev/null +++ b/blocks/header/combobox.js @@ -0,0 +1,302 @@ +import { filterItems } from './combobox-filter.js'; + +export const CHEVRON_SVG = ''; + +/** + * Creates a hand-rolled, Spectrum-styled combobox (filterable single-select with free text). + * Follows the WAI-ARIA combobox pattern with aria-autocomplete="list". + * @param {Object} options + * @param {string} options.id - Base id for the input; the listbox gets `${id}-listbox`. + * @param {string} options.label - Accessible label for the input. + * @param {string} options.placeholder - Input placeholder text. + * @param {boolean} [options.disabled=false] - Whether the combobox starts disabled. + * @param {boolean} [options.labelVisible=false] - Render `label` as a visible `` above the + * field (and rely on it for the accessible name instead of an `aria-label`). + * @returns {{ + * element: HTMLDivElement, + * getValue: () => string, + * setValue: (v: string) => void, + * setItems: (items: string[]) => void, + * setDisabled: (b: boolean) => void, + * on: (eventName: string, cb: (value: string) => void) => void, + * off: (eventName: string, cb: (value: string) => void) => void, + * }} + */ +export function createCombobox({ + id, label, placeholder, disabled = false, labelVisible = false, +}) { + const listboxId = `${id}-listbox`; + + const element = document.createElement('div'); + element.className = 'combobox'; + if (disabled) element.classList.add('is-disabled'); + + if (labelVisible) { + const labelEl = document.createElement('label'); + labelEl.className = 'combobox-label'; + labelEl.setAttribute('for', id); + labelEl.textContent = label; + element.append(labelEl); + } + + const field = document.createElement('div'); + field.className = 'combobox-field'; + + const input = document.createElement('input'); + input.type = 'text'; + input.id = id; + input.name = id; + input.className = 'combobox-input'; + input.setAttribute('role', 'combobox'); + input.setAttribute('aria-expanded', 'false'); + input.setAttribute('aria-autocomplete', 'list'); + input.setAttribute('aria-controls', listboxId); + if (!labelVisible) input.setAttribute('aria-label', label); + input.setAttribute('autocomplete', 'off'); + if (placeholder) input.placeholder = placeholder; + if (disabled) input.disabled = true; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'combobox-button'; + button.tabIndex = -1; + button.setAttribute('aria-hidden', 'true'); + button.innerHTML = CHEVRON_SVG; + + field.append(input, button); + + const listbox = document.createElement('ul'); + listbox.className = 'combobox-listbox'; + listbox.id = listboxId; + listbox.setAttribute('role', 'listbox'); + listbox.hidden = true; + + element.append(field, listbox); + + let allItems = []; + let lastCommitted = ''; + let activeIndex = -1; + const listeners = { commit: new Set() }; + + const isOpen = () => !listbox.hidden; + + const emit = (eventName, value) => { + (listeners[eventName] || []).forEach((cb) => cb(value)); + }; + + const optionElements = () => [...listbox.querySelectorAll('.combobox-option')]; + + const isSelectableOption = (li) => li && !li.classList.contains('is-empty') + && li.getAttribute('aria-disabled') !== 'true'; + + const clearActive = () => { + optionElements().forEach((li) => li.classList.remove('is-active')); + input.removeAttribute('aria-activedescendant'); + activeIndex = -1; + }; + + const setActive = (index) => { + const options = optionElements(); + optionElements().forEach((li) => li.classList.remove('is-active')); + if (index < 0 || index >= options.length || !isSelectableOption(options[index])) { + input.removeAttribute('aria-activedescendant'); + activeIndex = -1; + return; + } + activeIndex = index; + const active = options[index]; + active.classList.add('is-active'); + input.setAttribute('aria-activedescendant', active.id); + active.scrollIntoView({ block: 'nearest' }); + }; + + const renderOptions = (matches) => { + listbox.replaceChildren(); + if (matches.length === 0) { + const li = document.createElement('li'); + li.className = 'combobox-option is-empty'; + li.setAttribute('role', 'option'); + li.setAttribute('aria-disabled', 'true'); + li.textContent = 'No matches'; + listbox.append(li); + return; + } + matches.forEach((item, i) => { + const li = document.createElement('li'); + li.className = 'combobox-option'; + li.setAttribute('role', 'option'); + li.id = `${id}-opt-${i}`; + li.textContent = item; + listbox.append(li); + }); + }; + + const open = (items) => { + if (input.disabled) return; + renderOptions(items); + listbox.hidden = false; + input.setAttribute('aria-expanded', 'true'); + clearActive(); + }; + + const close = () => { + listbox.hidden = true; + input.setAttribute('aria-expanded', 'false'); + input.removeAttribute('aria-activedescendant'); + activeIndex = -1; + }; + + const commit = (value) => { + input.value = value; + close(); + if (value !== lastCommitted) { + lastCommitted = value; + emit('commit', value); + } + }; + + const commitActiveOrTyped = () => { + if (isOpen() && activeIndex >= 0) { + const options = optionElements(); + const active = options[activeIndex]; + if (isSelectableOption(active)) { + commit(active.textContent); + return; + } + } + commit(input.value); + }; + + const moveActive = (delta) => { + const options = optionElements(); + if (options.length === 0) return; + let next = activeIndex; + if (next < 0) { + next = delta > 0 ? 0 : options.length - 1; + } else { + next += delta; + } + while (next >= 0 && next < options.length && !isSelectableOption(options[next])) { + next += delta; + } + if (next < 0 || next >= options.length) return; + setActive(next); + }; + + input.addEventListener('input', () => { + if (input.disabled) return; + const matches = filterItems(input.value, allItems); + renderOptions(matches); + listbox.hidden = false; + input.setAttribute('aria-expanded', 'true'); + clearActive(); + }); + + input.addEventListener('keydown', (e) => { + if (input.disabled) return; + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (!isOpen()) { + open([...allItems]); + setActive(0); + } else { + moveActive(1); + } + break; + case 'ArrowUp': + if (isOpen()) { + e.preventDefault(); + moveActive(-1); + } + break; + case 'Enter': + if (isOpen()) { + e.preventDefault(); + commitActiveOrTyped(); + } else { + commit(input.value); + } + break; + case 'Escape': + if (isOpen()) { + e.preventDefault(); + e.stopPropagation(); + close(); + } + break; + case 'Tab': + if (isOpen()) close(); + break; + default: + break; + } + }); + + button.addEventListener('mousedown', (e) => { + // Keep focus in the input so the chevron toggle does not trigger a blur commit. + e.preventDefault(); + }); + + button.addEventListener('click', () => { + if (input.disabled) return; + if (isOpen()) { + close(); + } else { + open([...allItems]); + } + input.focus(); + }); + + listbox.addEventListener('mousedown', (e) => { + // Prevent the input from losing focus (which would trigger a blur commit) before the click. + e.preventDefault(); + }); + + listbox.addEventListener('click', (e) => { + const li = e.target.closest('.combobox-option'); + if (!li || !isSelectableOption(li)) return; + commit(li.textContent); + input.focus(); + }); + + element.addEventListener('focusout', (e) => { + if (e.relatedTarget && element.contains(e.relatedTarget)) return; + commit(input.value); + }); + + document.addEventListener('click', (e) => { + if (element.contains(e.target)) return; + if (isOpen()) close(); + }); + + return { + element, + getValue: () => input.value, + setValue: (v) => { + const value = v == null ? '' : String(v); + input.value = value; + lastCommitted = value; + }, + setItems: (items) => { + allItems = [...items].filter((i) => i != null && String(i).trim() !== ''); + if (isOpen()) { + const matches = filterItems(input.value, allItems); + renderOptions(matches); + clearActive(); + } + }, + setDisabled: (b) => { + input.disabled = !!b; + element.classList.toggle('is-disabled', !!b); + if (b) close(); + }, + on: (eventName, cb) => { + if (!listeners[eventName]) listeners[eventName] = new Set(); + listeners[eventName].add(cb); + }, + off: (eventName, cb) => { + if (listeners[eventName]) listeners[eventName].delete(cb); + }, + }; +} diff --git a/blocks/header/header.css b/blocks/header/header.css index 16d48441..a70d9387 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -1,6 +1,9 @@ /* stylelint-disable no-descending-specificity */ header.header-wrapper { - padding: var(--spacing-xs) var(--horizontal-spacing); + position: sticky; + top: 0; + z-index: 10; + background: var(--color-background); &:has(.experimental-ribbon) { padding-top: 0; @@ -16,6 +19,15 @@ header.header-wrapper { text-decoration: none; } + .button:focus, button:focus { + outline: 0; + } + + .button:focus-visible, button:focus-visible { + outline: 2px solid var(--color-link); + outline-offset: 2px; + } + ul { list-style: none; margin: 0; @@ -25,120 +37,173 @@ header.header-wrapper { .header-nav { display: flex; align-items: center; - justify-content: space-between; + justify-content: start; position: relative; gap: var(--spacing-m); margin: 0 auto; max-width: 1200px; + flex-wrap: wrap; + min-height: var(--header-height); + padding: 0 var(--spacing-m); + margin-bottom: var(--spacing-m); - button.toggle-nav { - color: var(--color-text); - border-color: currentcolor; - position: relative; - padding-inline-end: calc(1.15em + 1em); - - .toggle-nav-icon { - position: absolute; - right: 0.5em; - top: 50%; - transform: translate(0%, -50%); - width: 1em; - height: 1em; - - &::before, &::after { - content: ''; - position: absolute; - top: 50%; - right: 0; - width: 50%; - height: 2px; - background-color: currentcolor; - transform: translate(0%, -50%) rotate(-45deg); - transition: all 0.3s ease-in-out; + .nav-tools { + display: flex; + align-items: center; + gap: var(--spacing-s); + margin-left: auto; + + .header-project { + display: inline-flex; + align-items: flex-end; + gap: var(--spacing-xs); + flex-wrap: wrap; + max-width: 50vw; + + .header-project-sep { + color: var(--color-font-grey); + padding-bottom: var(--spacing-xs); } - &::after { - right: 50%; - transform: translate(50%, -50%) rotate(45deg); + @media (width >= 900px) { + flex-wrap: nowrap; + max-width: none; } } - &[aria-expanded='true'] { - .toggle-nav-icon { - &::before, &::after { - width: 100%; - transform: translate(0%, -50%) rotate(45deg); - } + .combobox { + position: relative; + display: inline-block; + } - &::after { - transform: translate(50%, -50%) rotate(-45deg); - } - } + .combobox-label { + display: block; + margin-bottom: var(--spacing-75); + font-size: var(--detail-size-s); + color: var(--color-font-grey); } - } + .combobox-field { + display: flex; + align-items: center; + min-height: 32px; + border: 1px solid var(--color-border); + border-radius: var(--rounding-s); + background: var(--color-background); + box-sizing: border-box; - .nav-sections { - position: fixed; - top: calc(var(--header-height) + var(--spacing-s)); - bottom: var(--spacing-s); - max-width: 325px; - display: none; - opacity: 0; - background: var(--color-background); - padding: var(--spacing-l); - box-shadow: var(--shadow-default); - z-index: 1; - border-radius: var(--rounding-l); - transition-property: all; - transition-duration: .5s; - transition-timing-function: cubic-bezier(.4,0,.2,1); - transition-behavior: allow-discrete; - overflow-y: auto; - overscroll-behavior: contain; + &:hover { + border-color: var(--gray-600); + } - &[aria-hidden='false'] { - display: block; - opacity: 1; + &:focus-within { + border-color: var(--gray-800); + } - @starting-style { - opacity: 0; + &:has(.combobox-input:focus-visible) { + outline: 2px solid var(--color-link); + outline-offset: 1px; } } - @media screen and (prefers-reduced-motion: reduce) { - transition: none; + .combobox-input { + border: 0; + background: transparent; + color: var(--color-text); + font-size: var(--body-size-s); + font-family: inherit; + padding: 0 var(--spacing-100); + min-width: 0; + width: 9rem; + outline: none; + + &::placeholder { + color: var(--color-font-grey); + } } - nav > ul { - display: grid; - gap: var(--spacing-l); + .combobox-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + flex-shrink: 0; + border: 0; + background: transparent; + color: var(--color-font-grey); + cursor: pointer; - .subsection { - display: flex; - flex-direction: column; - gap: var(--spacing-m); + svg { + width: 12px; + height: 12px; + } + } - span { - font-weight: 600; - } + .combobox-field:hover .combobox-button { + color: var(--color-text); + } - ul { - display: grid; - gap: var(--spacing-s); - margin-block-end: var(--spacing-s); + .combobox.is-disabled .combobox-field { + background: var(--color-button-disabled-bg); + border-color: var(--color-border); + } - a { - display: inline-flex; - align-items: center; - gap: var(--spacing-xxs); - } - } + .combobox.is-disabled .combobox-input { + color: var(--color-font-grey); + cursor: not-allowed; + } + + .combobox.is-disabled .combobox-button { + color: var(--gray-400); + cursor: not-allowed; + } + + .combobox-listbox { + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 100%; + max-height: 16rem; + overflow-y: auto; + display: flex; + flex-direction: column; + margin: 0; + padding: var(--spacing-75) 0; + list-style: none; + background: var(--color-background); + border: 1px solid var(--gray-300); + border-radius: var(--rounding-m); + box-shadow: var(--shadow-default); + z-index: 30; + + &[hidden] { + display: none; + } + } + + .combobox-option { + display: block; + padding: var(--spacing-75) var(--spacing-200); + font-size: var(--body-size-s); + color: var(--color-text); + cursor: pointer; + white-space: nowrap; + + &:hover, + &.is-active { + background: var(--layer-depth); + } + + &.is-empty { + color: var(--color-font-grey); + cursor: default; + } + + &.is-empty:hover { + background: transparent; } } - } - .nav-tools { .tools-list { display: flex; gap: var(--spacing-m); @@ -153,10 +218,21 @@ header.header-wrapper { gap: var(--spacing-s); font-size: var(--body-size-m); color: var(--color-text); - border-color: var(--color-text); + border: 0; + outline: 0; + padding-right: 12px; + + &:hover { + background: none; + } .icon { line-height: 0; + + svg { + width: 24px; + height: 24px; + } } @media screen and (width <= 600px) { @@ -186,8 +262,6 @@ header.header-wrapper { svg { width: 20px; height: 20px; - fill: currentcolor; - stroke: currentcolor; } &:focus-visible { @@ -196,6 +270,18 @@ header.header-wrapper { } } } + + .nav-title { + .default-content-wrapper { + display: flex; + gap: var(--spacing-xs); + align-items: center; + } + + img { + max-width: 30px; + } + } } .experimental-ribbon { @@ -301,5 +387,244 @@ header.header-wrapper { font-size: 0.95em; line-height: 1; } + + .header-mega { + position: relative; + } + + .header-mega-trigger { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: var(--rounding-s); + padding: var(--spacing-xs) var(--spacing-s); + font-size: var(--body-size-s); + color: var(--color-text); + font-weight: 600; + cursor: pointer; + + .header-mega-chevron { + display: inline-flex; + line-height: 0; + } + + svg { + width: 12px; + height: 12px; + } + + &:hover { + background: var(--layer-depth); + } + + &.is-open { + background: var(--color-link); + color: var(--gray-25); + border-color: var(--color-link); + } + } + + .header-mega-panel { + position: absolute; + top: calc(100% + 6px); + left: 0; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: var(--rounding-s); + box-shadow: 0 8px 32px rgb(0 0 0 / 12%); + padding: var(--spacing-l); + display: grid; + grid-template-columns: 1fr; + gap: var(--spacing-l); + min-width: 280px; + z-index: 20; + + &[hidden] { + display: none; + } + + @media (width >= 900px) { + grid-template-columns: 1fr 1fr 1fr; + min-width: 600px; + } + } + + .header-mega-col h4 { + margin: 0 0 var(--spacing-xs); + font-size: var(--body-size-s); + text-transform: uppercase; + letter-spacing: .05em; + color: var(--color-font-grey); + padding-bottom: var(--spacing-xs); + border-bottom: 1px solid var(--color-border); + } + + .header-mega-col ul { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 2px; + } + + .header-mega-col li a { + display: block; + padding: var(--spacing-xs) var(--spacing-s); + color: var(--color-link); + text-decoration: none; + border-radius: var(--rounding-s); + font-size: var(--body-size-s); + + &:hover { + background: var(--layer-depth); + } + } + + /* Responsive nav: an inline flex container on wide viewports, a hamburger-toggled + left slide-in drawer below 900px (the toggle / close / backdrop are display:none above it). */ + .header-drawer { + display: flex; + flex: 1; + align-items: center; + gap: var(--spacing-m); + } + + .header-nav-toggle, + .header-drawer-close, + .header-drawer-backdrop { + display: none; + } + + @media (width < 900px) { + .header-nav-toggle { + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; + padding: 0.4em; + border: 0; + background: transparent; + color: var(--color-text); + cursor: pointer; + + svg { + width: 24px; + height: 24px; + } + } + + .header-drawer { + position: fixed; + inset: 0 auto 0 0; + z-index: 1000; + width: min(85vw, 320px); + flex-direction: column; + align-items: stretch; + gap: var(--spacing-m); + padding: var(--spacing-m); + overflow-y: auto; + overscroll-behavior: contain; + background: var(--color-background); + box-shadow: var(--shadow-default); + visibility: hidden; + transform: translateX(-100%); + transition: transform 0.25s ease, visibility 0s 0.25s; + } + + &.is-nav-open .header-drawer { + visibility: visible; + transform: translateX(0); + transition: transform 0.25s ease; + } + + .header-drawer-close { + display: flex; + align-self: flex-end; + align-items: center; + justify-content: center; + padding: 0.4em; + border: 0; + background: transparent; + color: var(--color-text); + cursor: pointer; + + svg { + width: 20px; + height: 20px; + } + } + + .header-drawer-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 999; + background: var(--transparent-black-600); + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease; + } + + &.is-nav-open .header-drawer-backdrop { + opacity: 1; + pointer-events: auto; + } + + .header-drawer .header-mega { + align-self: stretch; + } + + .header-drawer .header-mega-trigger { + width: 100%; + text-align: left; + } + + .header-drawer .header-mega-panel { + position: static; + min-width: 0; + padding: var(--spacing-s) 0 0; + border: 0; + box-shadow: none; + } + + .header-drawer .nav-tools { + flex-direction: column; + align-items: stretch; + gap: var(--spacing-m); + margin-left: 0; + } + + .header-drawer .nav-tools .header-project { + flex-flow: column nowrap; + align-items: stretch; + max-width: none; + } + + .header-drawer .nav-tools .header-project .header-project-sep { + display: none; + } + + .header-drawer .nav-tools .combobox { + display: block; + } + + .header-drawer .nav-tools .combobox-input { + width: auto; + flex: 1; + } + + .header-drawer .nav-tools .combobox-listbox { + position: static; + margin-top: var(--spacing-75); + box-shadow: none; + } + + .header-drawer .nav-tools .tools-list { + flex-wrap: wrap; + justify-content: flex-start; + } + } } } diff --git a/blocks/header/header.js b/blocks/header/header.js index 5852bcac..47265a58 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -6,10 +6,17 @@ import { storeTheme, } from '../../scripts/scripts.js'; import { loadFragment } from '../fragment/fragment.js'; +import { parseCategories } from './categories.js'; +import { createCombobox, CHEVRON_SVG } from './combobox.js'; +import { + loadProjects, + updateStorage, + getProjectFromUrl, +} from '../../utils/config/config.js'; // Theme toggle constants -const THEMES = ['system', 'light', 'dark']; -const themeIconsCache = {}; +const CONTRAST_ICON_URL = '/icons/s2-icon-contrast-20-n.svg'; +let contrastIconMarkup = null; const THEME_NAMES = { system: 'System', @@ -20,13 +27,12 @@ const THEME_NAMES = { const EXPERIMENTAL_TOOLTIP = 'Experimental tools should be considered early-access: they may undergo significant changes without warning and are not yet widely adopted.'; function getNextTheme(current) { + if (current === 'dark') return 'light'; + if (current === 'light') return 'dark'; + // No explicit preference yet ('system'): switch away from whatever the OS resolves to, + // and from here on the toggle just flips light <-> dark (never back to 'system'). const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches; - const systemResolved = prefersDark ? 'dark' : 'light'; - const opposite = prefersDark ? 'light' : 'dark'; - - if (current === 'system') return opposite; - if (current === opposite) return systemResolved; - return 'system'; + return prefersDark ? 'light' : 'dark'; } function getThemeLabel(current) { @@ -58,49 +64,6 @@ function attachTooltip(ribbon, tooltip) { tooltip.addEventListener('focusout', hide); } -async function isLabTool(url) { - try { - const resp = await fetch(url); - if (!resp.ok) return false; - const html = await resp.text(); - const doc = new DOMParser().parseFromString(html, 'text/html'); - return doc.querySelector('meta[name="lab"][content="true"]') !== null; - } catch { - return false; - } -} - -async function decorateLabToolLink(link) { - const isLab = await isLabTool(link.href); - if (!isLab || link.querySelector('.experimental-icon')) return; - const icon = document.createElement('span'); - icon.className = 'experimental-icon'; - icon.textContent = '๐งช'; - icon.setAttribute('aria-hidden', 'true'); - link.append(icon); -} - -function observeLabToolLinks(container) { - const links = [...container.querySelectorAll('a[href]')]; - if (!('IntersectionObserver' in window)) { - links.forEach((link) => { - setTimeout(() => decorateLabToolLink(link), 0); - }); - return; - } - - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting) return; - const link = entry.target; - observer.unobserve(link); - decorateLabToolLink(link); - }); - }, { rootMargin: '200px' }); - - links.forEach((link) => observer.observe(link)); -} - function decorateExperimentalRibbon(block) { const ribbon = document.createElement('div'); const tooltip = document.createElement('span'); @@ -128,32 +91,25 @@ function decorateExperimentalRibbon(block) { block.prepend(ribbon); } -async function fetchThemeIcon(theme) { - if (themeIconsCache[theme]) return themeIconsCache[theme]; +async function fetchContrastIcon() { + if (contrastIconMarkup !== null) return contrastIconMarkup; try { - const response = await fetch(`/icons/theme-${theme}.svg`); - if (response.ok) { - const svg = await response.text(); - themeIconsCache[theme] = svg; - return svg; - } + const response = await fetch(CONTRAST_ICON_URL); + contrastIconMarkup = response.ok ? await response.text() : ''; } catch (e) { - // Fetch failed + contrastIconMarkup = ''; } - return ''; + return contrastIconMarkup; } async function updateThemeButton(button, theme) { - const svg = await fetchThemeIcon(theme); - button.innerHTML = svg; + button.innerHTML = await fetchContrastIcon(); button.setAttribute('aria-label', getThemeLabel(theme)); button.setAttribute('title', getThemeLabel(theme)); } async function initThemeToggle(button) { let currentTheme = getStoredTheme(); - // Preload all icons - await Promise.all(THEMES.map((theme) => fetchThemeIcon(theme))); await updateThemeButton(button, currentTheme); button.addEventListener('click', async () => { currentTheme = getNextTheme(currentTheme); @@ -163,52 +119,207 @@ async function initThemeToggle(button) { }); } -function clickToggleListener(e) { - const inNav = e.target.closest('.header-nav'); - if (!inNav) { - const button = document.getElementById('toggle-nav'); - const sections = document.getElementById('nav-sections'); - // eslint-disable-next-line no-use-before-define - toggleNav(button, sections); - } -} +// Shares the `aem-projects` localStorage shape and sidekick `getSites` source with the +// per-tool org/site pickers (see utils/config/config.js). Header-prefixed ids keep +// this picker's inputs distinct from any per-tool form inputs. +function buildProjectFields() { + const wrap = document.createElement('div'); + wrap.className = 'header-project'; -function keyToggleListener(e) { - if (e.key === 'Escape') { - const button = document.getElementById('toggle-nav'); - const sections = document.getElementById('nav-sections'); - // eslint-disable-next-line no-use-before-define - toggleNav(button, sections); - button.focus(); - } + const orgCombo = createCombobox({ + id: 'header-org', label: 'Organization', placeholder: 'Select org', labelVisible: true, + }); + const sep = document.createElement('span'); + sep.className = 'header-project-sep'; + sep.textContent = '/'; + const siteCombo = createCombobox({ + id: 'header-site', label: 'Site', placeholder: 'site', disabled: true, labelVisible: true, + }); + wrap.append(orgCombo.element, sep, siteCombo.element); + + let projects = { orgs: [], sitesByOrg: {} }; + + const persist = () => { + const org = orgCombo.getValue(); + if (!org) return; + const site = siteCombo.getValue(); + updateStorage(org, site); + const url = new URL(window.location.href); + url.searchParams.set('org', org); + if (site) url.searchParams.set('site', site); + else url.searchParams.delete('site'); + window.history.replaceState({}, '', url); + window.dispatchEvent(new CustomEvent('tools:project-change', { detail: { org, site } })); + }; + + const refillSites = (org) => { + const sites = (org && projects.sitesByOrg[org]) || []; + siteCombo.setItems(sites); + const current = siteCombo.getValue(); + if (current && !sites.includes(current)) siteCombo.setValue(''); + siteCombo.setDisabled(!org); + }; + + orgCombo.on('commit', (org) => { refillSites(org); persist(); }); + siteCombo.on('commit', () => persist()); + + (async () => { + const { org: urlOrg, site: urlSite } = getProjectFromUrl(); + if (urlOrg) orgCombo.setValue(urlOrg); + if (urlSite) siteCombo.setValue(urlSite); + projects = await loadProjects(); + orgCombo.setItems(projects.orgs); + let org = orgCombo.getValue(); + if (!org && projects.orgs[0]) { + [org] = projects.orgs; + orgCombo.setValue(org); + } + if (org) { + const sites = projects.sitesByOrg[org] || []; + siteCombo.setItems(sites); + siteCombo.setDisabled(false); + if (!siteCombo.getValue() && sites[0]) siteCombo.setValue(sites[0]); + persist(); + } + })(); + + return wrap; } -function closeOnFocusLost(e) { - const nav = e.currentTarget; - if ((nav && e.relatedTarget) && !nav.contains(e.relatedTarget)) { - const button = nav.querySelector('.toggle-nav'); - const sections = nav.querySelector('.nav-sections'); - // eslint-disable-next-line no-use-before-define - toggleNav(button, sections); - } +function buildMegaNav(categories) { + const wrap = document.createElement('div'); + wrap.className = 'header-mega'; + + const trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.className = 'header-mega-trigger'; + trigger.setAttribute('aria-haspopup', 'true'); + trigger.setAttribute('aria-expanded', 'false'); + trigger.setAttribute('aria-controls', 'header-mega-panel'); + const triggerLabel = document.createTextNode('Tools'); + const chevron = document.createElement('span'); + chevron.className = 'header-mega-chevron'; + chevron.setAttribute('aria-hidden', 'true'); + chevron.innerHTML = CHEVRON_SVG; + trigger.append(triggerLabel, chevron); + + const panel = document.createElement('nav'); + panel.id = 'header-mega-panel'; + panel.className = 'header-mega-panel'; + panel.setAttribute('aria-label', 'Tools'); + panel.hidden = true; + + categories.forEach((cat) => { + const col = document.createElement('div'); + col.className = 'header-mega-col'; + const heading = document.createElement('h4'); + heading.textContent = cat.label; + col.append(heading); + const ul = document.createElement('ul'); + cat.tools.forEach((tool) => { + const li = document.createElement('li'); + const a = document.createElement('a'); + a.href = tool.url; + a.textContent = tool.label; + li.append(a); + ul.append(li); + }); + col.append(ul); + panel.append(col); + }); + + wrap.append(trigger, panel); + + const close = () => { + panel.hidden = true; + trigger.setAttribute('aria-expanded', 'false'); + trigger.classList.remove('is-open'); + }; + const open = () => { + panel.hidden = false; + trigger.setAttribute('aria-expanded', 'true'); + trigger.classList.add('is-open'); + }; + + trigger.addEventListener('click', (e) => { + e.stopPropagation(); + if (panel.hidden) open(); + else close(); + }); + + document.addEventListener('click', (e) => { + if (panel.hidden) return; + if (!wrap.contains(e.target)) close(); + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !panel.hidden) { + close(); + trigger.focus(); + } + }); + + return wrap; } -function toggleNav(button, sections) { - const expanded = button.getAttribute('aria-expanded') === 'true'; - button.setAttribute('aria-expanded', !expanded); - sections.setAttribute('aria-hidden', expanded); - button.setAttribute('aria-label', !expanded ? 'Close navigation' : 'Open navigation'); - - const nav = button.closest('#nav'); - if (!expanded) { - document.addEventListener('click', clickToggleListener); - window.addEventListener('keydown', keyToggleListener); - nav.addEventListener('focusout', closeOnFocusLost); - } else { - document.removeEventListener('click', clickToggleListener); - window.removeEventListener('keydown', keyToggleListener); - nav.removeEventListener('focusout', closeOnFocusLost); - } +const HAMBURGER_SVG = ''; +const CLOSE_SVG = ''; + +/** + * Collapses the header behind a hamburger on narrow viewports: the "Tools" nav and + * the right-side tools (org/site pickers, links, theme toggle, profile) move into a + * left slide-in drawer. On wide viewports the drawer is just an inline flex container + * and the toggle/backdrop are hidden by CSS โ no DOM is moved back. + * @param {Element} nav The `.header-nav` element. + * @param {Element} block The header block element. + */ +function setupResponsiveNav(nav, block) { + const drawer = document.createElement('div'); + drawer.className = 'header-drawer'; + drawer.id = 'header-drawer'; + + const closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.className = 'header-drawer-close'; + closeButton.setAttribute('aria-label', 'Close menu'); + closeButton.innerHTML = CLOSE_SVG; + drawer.append(closeButton); + + // everything but the logo moves into the drawer (in document order: mega, then tools) + nav.querySelectorAll(':scope > .header-mega, :scope > .nav-tools').forEach((el) => drawer.append(el)); + nav.append(drawer); + + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'header-nav-toggle'; + toggle.setAttribute('aria-label', 'Open menu'); + toggle.setAttribute('aria-expanded', 'false'); + toggle.setAttribute('aria-controls', 'header-drawer'); + toggle.innerHTML = HAMBURGER_SVG; + nav.append(toggle); + + const backdrop = document.createElement('div'); + backdrop.className = 'header-drawer-backdrop'; + block.append(backdrop); + + const setOpen = (open) => { + block.classList.toggle('is-nav-open', open); + toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + toggle.setAttribute('aria-label', open ? 'Close menu' : 'Open menu'); + if (open) document.body.setAttribute('data-scroll', 'disabled'); + else document.body.removeAttribute('data-scroll'); + }; + + toggle.addEventListener('click', () => setOpen(!block.classList.contains('is-nav-open'))); + closeButton.addEventListener('click', () => setOpen(false)); + backdrop.addEventListener('click', () => setOpen(false)); + drawer.addEventListener('click', (e) => { if (e.target.closest('a[href]')) setOpen(false); }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && block.classList.contains('is-nav-open')) { + setOpen(false); + toggle.focus(); + } + }); } /** @@ -227,6 +338,13 @@ export default async function decorate(block) { nav.classList.add('header-nav'); while (fragment.firstElementChild) nav.append(fragment.firstElementChild); + const navHtml = nav.innerHTML; + const categories = parseCategories(navHtml); + window.toolCategories = Object.freeze(Object.fromEntries( + categories.map((c) => [c.slug, Object.freeze({ label: c.label, tools: c.tools })]), + )); + window.dispatchEvent(new CustomEvent('tools:categories-ready', { detail: { categories } })); + const classes = ['title', 'sections', 'tools']; classes.forEach((c, i) => { const section = nav.children[i]; @@ -236,68 +354,31 @@ export default async function decorate(block) { } }); - // decorate title + // decorate title as a plain logo link (no dropdown trigger) const title = nav.querySelector('.nav-title'); const sections = nav.querySelector('.nav-sections'); - if (title) { - if (sections) { - // make button - const button = document.createElement('button'); - button.classList.add('button', 'outline', 'toggle-nav'); - button.id = 'toggle-nav'; - button.setAttribute('aria-label', 'Open navigation'); - button.setAttribute('aria-haspopup', true); - button.setAttribute('aria-expanded', false); - button.setAttribute('aria-controls', 'nav-sections'); - button.textContent = title.textContent; - title.replaceWith(button); - - const buttonIcon = document.createElement('span'); - buttonIcon.classList.add('toggle-nav-icon'); - button.append(buttonIcon); - - button.addEventListener('click', () => { - toggleNav(button, sections); - }); - - sections.setAttribute('aria-hidden', true); - } else if (!title.querySelector('a[href]')) { - const content = title.querySelector('h1, h2, h3, h4, h5, h6, p'); + if (title && !title.querySelector('a[href]')) { + const content = title.querySelector('h1, h2, h3, h4, h5, h6, p'); + if (content && content.textContent) { content.className = 'title-content'; - if (content && content.textContent) { - const link = document.createElement('a'); - link.href = '/'; - link.innerHTML = content.innerHTML; - content.innerHTML = link.outerHTML; - } + const link = document.createElement('a'); + link.href = '/'; + link.innerHTML = content.innerHTML; + content.innerHTML = link.outerHTML; } } - // decorate sections - if (sections) { - const wrapper = document.createElement('nav'); - const ul = sections.querySelector('ul'); - wrapper.append(ul); - sections.prepend(wrapper); - [...ul.children].forEach((li) => { - const subsection = li.querySelector('ul'); - if (subsection) { - li.className = 'subsection'; - const label = li.textContent.replace(subsection.textContent, '').trim(); - if (label) { - const span = document.createElement('span'); - span.textContent = label; - li.replaceChildren(span, subsection); - } - } - }); - observeLabToolLinks(wrapper); - } + // drop the original nav-sections markup; replace it with the mega-nav. + if (sections) sections.remove(); // add login button const tools = nav.querySelector('.nav-tools'); + // place the mega-nav between the title and the tools area, where nav-sections used to live. + if (tools) nav.insertBefore(buildMegaNav(categories), tools); + else nav.append(buildMegaNav(categories)); if (tools) { const toolsList = tools.querySelector('ul'); + tools.prepend(buildProjectFields()); toolsList.classList.add('tools-list'); toolsList.querySelectorAll('a').forEach((a) => { @@ -343,6 +424,8 @@ export default async function decorate(block) { navWrapper.append(nav); block.replaceChildren(navWrapper); + setupResponsiveNav(nav, block); + // add experimental ribbon for pages with lab metadata const isLab = getMetadata('lab') === 'true'; if (isLab) { diff --git a/blocks/profile/profile.css b/blocks/profile/profile.css index 07352e6a..38f13b76 100644 --- a/blocks/profile/profile.css +++ b/blocks/profile/profile.css @@ -4,10 +4,11 @@ .profile #profile { display: block; - width: 32px; - height: 32px; + width: 24px; + height: 24px; border-radius: 50%; cursor: pointer; + color: var(--color-text); } .profile #profile .icon svg { diff --git a/blocks/profile/profile.js b/blocks/profile/profile.js index 92116fc4..3a8fad08 100644 --- a/blocks/profile/profile.js +++ b/blocks/profile/profile.js @@ -434,13 +434,20 @@ async function showModal(block, focusedOrg) { dialog.showModal(); } +const AVATAR_ICON_URL = '/icons/S2_Icon_UserAvatar_20_N.svg'; + +async function loadAvatarIcon(iconSpan) { + try { + const resp = await fetch(AVATAR_ICON_URL); + if (resp.ok) iconSpan.innerHTML = await resp.text(); + } catch (e) { + // leave the avatar empty if the icon can't be fetched + } +} + export default async function decorate(block) { const avatar = document.createElement('button'); - avatar.innerHTML = ` - - - - `; + avatar.innerHTML = ''; avatar.id = 'profile'; avatar.setAttribute('type', 'button'); avatar.setAttribute('title', 'Manage projects and sign in'); @@ -450,6 +457,7 @@ export default async function decorate(block) { showModal(block); }); block.append(avatar); + loadAvatarIcon(avatar.querySelector('.icon')); dispatchProfileEvent('loaded'); } diff --git a/blocks/recent-tools/recent-tools.css b/blocks/recent-tools/recent-tools.css index 4be7e3a2..0cd3a8eb 100644 --- a/blocks/recent-tools/recent-tools.css +++ b/blocks/recent-tools/recent-tools.css @@ -1,53 +1,44 @@ main > .section.recent-tools-container > div { - max-width: unset; - padding: var(--spacing-s) var(--spacing-m); - margin: 0; - border-bottom: 1px solid var(--color-border); + margin: var(--spacing-s) var(--horizontal-spacing); + padding: var(--spacing-m); + border: 1px solid var(--color-border); + border-radius: var(--rounding-l); } .recent-tools { - .recent-tools-nav { + .recent-tools-title { + margin: 0 0 var(--spacing-s); + font-size: var(--heading-size-m); + font-weight: var(--weight-regular); + } + + .recent-tools-nav ul { + margin: 0; + padding: 0; + list-style: none; display: flex; flex-wrap: wrap; - align-items: center; - gap: var(--spacing-s); - padding: 0 var(--horizontal-spacing); - - h2 { - margin: 0; - font-size: var(--body-size-s); - font-weight: 400; - color: color-mix(in srgb, currentcolor, transparent 40%); - } + gap: 6px; - ul { + > li { margin: 0; - padding: 0; - list-style: none; - display: flex; - flex-wrap: wrap; - gap: 8px; - - > li { - margin: 0; - a { - display: block; - font-size: var(--body-size-s); - text-decoration: none; - padding: 4px 14px; - border-radius: 100px; - border: 1px solid var(--color-border); - background: var(--color-background); - color: var(--color-text); - transition: background-color 0.15s, box-shadow 0.15s, border-color 0.15s; + a { + display: block; + font-size: var(--body-size-xs); + text-decoration: none; + padding: 3px 10px; + border-radius: 100px; + border: 1px solid var(--color-border); + background: var(--color-background); + color: var(--color-text); + transition: background-color 0.15s, box-shadow 0.15s, border-color 0.15s; - &:hover { - background: var(--color-link); - color: white; - border-color: var(--color-link); - box-shadow: 0 2px 8px rgb(59 99 251 / 30%); - } + &:hover { + background: var(--color-link); + color: white; + border-color: var(--color-link); + box-shadow: 0 2px 8px rgb(59 99 251 / 30%); } } } diff --git a/blocks/recent-tools/recent-tools.js b/blocks/recent-tools/recent-tools.js index 1fe6553e..61891ad9 100644 --- a/blocks/recent-tools/recent-tools.js +++ b/blocks/recent-tools/recent-tools.js @@ -1,5 +1,7 @@ const EXCLUDE_TOOLS = ['/tools/optel/']; +const TITLE_ID = 'recent-tools-title'; + function dedupKey(path) { return path.replace(/\/index\.html$/, '/').replace(/^(\/tools\/[^/]+)\.html$/, '$1/'); } @@ -27,20 +29,11 @@ function findTitle(path) { return tool ? tool.textContent : labelFromPath(path); } -function buildRecentNav(merged, heading, hasStoredVisits) { - if (!heading) { - // eslint-disable-next-line no-param-reassign - heading = document.createElement('h2'); - heading.textContent = 'Recent tools'; - } - if (!hasStoredVisits) heading.textContent = 'Quick Access'; - - heading.id = 'recent-tools-nav-heading'; +function buildRecentNav(merged) { const nav = document.createElement('nav'); nav.classList.add('recent-tools-nav'); - nav.setAttribute('aria-labelledby', heading.id); - nav.append(heading); - nav.innerHTML += ` + nav.setAttribute('aria-labelledby', TITLE_ID); + nav.innerHTML = ` ${merged.map((v) => `${findTitle(v.path)}`).join('')} @@ -50,6 +43,7 @@ function buildRecentNav(merged, heading, hasStoredVisits) { export default async function decorate(block) { const visits = JSON.parse(localStorage.getItem('aem-tool-visits') || '[]'); + const hasVisits = visits.length > 0; const links = [...block.querySelectorAll('a')].map((a) => { const path = new URL(a.href).pathname; return { path, ts: Date.now() }; @@ -59,6 +53,11 @@ export default async function decorate(block) { .map((item) => ({ ...item, key: dedupKey(item.path) })) .filter((item, i, arr) => arr.findIndex((v) => v.key === item.key) === i) .slice(0, 5); - const heading = block.querySelector('h2'); - block.replaceChildren(buildRecentNav(merged, heading, visits.length > 0)); + + const title = document.createElement('h1'); + title.id = TITLE_ID; + title.className = 'recent-tools-title'; + title.textContent = hasVisits ? 'Continue where you left off' : 'Quick access'; + + block.replaceChildren(title, buildRecentNav(merged)); } diff --git a/blocks/tool-catalog/filter.js b/blocks/tool-catalog/filter.js new file mode 100644 index 00000000..5549db2b --- /dev/null +++ b/blocks/tool-catalog/filter.js @@ -0,0 +1,34 @@ +function normalize(path) { + if (!path) return ''; + return path.replace(/\/index\.html$/, '/').replace(/\/?$/, '/'); +} + +export function matchesCategory(path, slug, map) { + if (!slug || slug === 'all') return true; + const bucket = map?.[slug]; + if (!bucket?.tools) return false; + const npath = normalize(path); + return bucket.tools.some((tool) => normalize(tool.url) === npath); +} + +/** + * Case-insensitive substring match used by the catalog search box. + * An empty/whitespace query matches everything. + * @param {string} text The card's text content. + * @param {string} query The raw search input value. + * @returns {boolean} + */ +export function matchesSearch(text, query) { + const q = (query || '').trim().toLowerCase(); + if (!q) return true; + return (text || '').toLowerCase().includes(q); +} + +export function parseCategoryFromUrl(href) { + try { + const url = new URL(href); + return url.searchParams.get('category'); + } catch { + return null; + } +} diff --git a/blocks/tool-catalog/tool-catalog.css b/blocks/tool-catalog/tool-catalog.css index cccaa363..e50e0d06 100644 --- a/blocks/tool-catalog/tool-catalog.css +++ b/blocks/tool-catalog/tool-catalog.css @@ -1,9 +1,57 @@ -.tool-catalog > form { - margin-bottom: var(--spacing-l); +.tool-catalog .tool-catalog-toolbar { + display: flex; + gap: var(--spacing-m); + align-items: flex-end; + margin-bottom: var(--spacing-m); + flex-wrap: wrap; +} + +.tool-catalog .tool-catalog-tabs { + display: flex; + gap: var(--spacing-m); + overflow-x: auto; + box-shadow: inset 0 -1px 0 0 var(--color-border); +} + +.tool-catalog .tool-catalog-tab { + padding: var(--spacing-s) var(--spacing-xs); + color: var(--color-font-grey); + font-size: var(--body-size-s); + font-weight: var(--weight-regular); + text-decoration: none; + white-space: nowrap; + box-shadow: inset 0 -2px 0 0 transparent; + transition: color 0.13s ease-in-out, box-shadow 0.13s ease-in-out; + + + + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--color-link); + outline-offset: -2px; + } +} + +.tool-catalog .tool-catalog-tab:hover { + color: var(--color-text); +} + +.tool-catalog .tool-catalog-tab.is-active { + color: var(--color-text); + box-shadow: inset 0 -2px 0 0 var(--color-link); +} + +.tool-catalog .tool-catalog-search { + min-width: 200px; + flex: 1; .form-field input { width: 100%; - padding: var(--spacing-s) var(--spacing-m); + box-sizing: border-box; + padding: var(--spacing-xs) var(--spacing-m); font-size: var(--body-size-m); border: 1px solid var(--color-border); border-radius: var(--rounding-l); @@ -12,6 +60,11 @@ } } +@media (width < 600px) { + .tool-catalog .tool-catalog-tabs { flex-basis: 100%; } + .tool-catalog .tool-catalog-search { flex-basis: 100%; } +} + .tool-catalog > ul { list-style: none; margin: 0; @@ -21,6 +74,17 @@ gap: var(--horizontal-spacing); } +.tool-catalog .tool-catalog-empty { + margin: 0; + padding: var(--spacing-xl) 0; + color: var(--color-font-grey); + font-size: var(--body-size-m); +} + +.tool-catalog .tool-catalog-empty[hidden] { + display: none; +} + .tool-catalog > ul > li { border-radius: var(--rounding-l); background-color: var(--layer-elevated); diff --git a/blocks/tool-catalog/tool-catalog.js b/blocks/tool-catalog/tool-catalog.js index 19945907..670b5c7b 100644 --- a/blocks/tool-catalog/tool-catalog.js +++ b/blocks/tool-catalog/tool-catalog.js @@ -1,4 +1,5 @@ import { createOptimizedPicture } from '../../scripts/aem.js'; +import { matchesCategory, matchesSearch, parseCategoryFromUrl } from './filter.js'; const labCache = new Map(); @@ -39,18 +40,33 @@ function observeLabStatus(ul) { ul.querySelectorAll(':scope > li').forEach((li) => observer.observe(li)); } +function refreshEmptyState(ul) { + const hasVisible = [...ul.querySelectorAll(':scope > li')].some((li) => !li.hidden); + const empty = ul.closest('.tool-catalog')?.querySelector('.tool-catalog-empty'); + if (empty) empty.hidden = hasVisible; +} + +function applyCategoryFilter(ul, slug) { + const map = window.toolCategories || {}; + ul.querySelectorAll(':scope > li').forEach((li) => { + const a = li.querySelector('a[href]'); + if (!a) return; + const path = new URL(a.getAttribute('href'), window.location.origin).pathname; + li.dataset.categoryHidden = matchesCategory(path, slug, map) ? '' : 'true'; + li.hidden = li.dataset.categoryHidden === 'true' || li.dataset.searchHidden === 'true'; + }); + refreshEmptyState(ul); +} + function handleSearchInput(e) { - const searchValue = e.target.value.trim().toLowerCase(); + const query = e.target.value; const ul = e.target.closest('.tool-catalog').querySelector('ul'); - const items = ul.querySelectorAll(':scope > li'); - items.forEach((li) => { - const text = li.querySelector('.cards-card-body')?.textContent.toLowerCase() || ''; - li.hidden = searchValue && !text.includes(searchValue); + ul.querySelectorAll(':scope > li').forEach((li) => { + const text = li.querySelector('.cards-card-body')?.textContent || ''; + li.dataset.searchHidden = matchesSearch(text, query) ? '' : 'true'; + li.hidden = li.dataset.searchHidden === 'true' || li.dataset.categoryHidden === 'true'; }); - const hasVisible = [...items].some((li) => !li.hidden); - if (!hasVisible) { - items.forEach((li) => { li.hidden = false; }); - } + refreshEmptyState(ul); } export default async function decorate(block) { @@ -76,14 +92,31 @@ export default async function decorate(block) { }); block.replaceChildren(ul); - // add search form + // add toolbar (tabs + search) above the grid + const toolbar = document.createElement('div'); + toolbar.className = 'tool-catalog-toolbar'; + + const tabBar = document.createElement('nav'); + tabBar.className = 'tool-catalog-tabs'; + tabBar.setAttribute('role', 'tablist'); + tabBar.setAttribute('aria-label', 'Tool categories'); + const searchForm = document.createElement('form'); + searchForm.className = 'tool-catalog-search'; searchForm.innerHTML = ` - + `; - block.prepend(searchForm); + + toolbar.append(tabBar, searchForm); + block.prepend(toolbar); + + const emptyMessage = document.createElement('p'); + emptyMessage.className = 'tool-catalog-empty'; + emptyMessage.textContent = 'No results found.'; + emptyMessage.hidden = true; + block.append(emptyMessage); const searchInput = searchForm.querySelector('input'); // Debounce function @@ -97,5 +130,50 @@ export default async function decorate(block) { searchInput.addEventListener('input', debounce(handleSearchInput, 200)); + function renderTabs(active) { + const categoriesMap = window.toolCategories || {}; + const slugs = ['all', ...Object.keys(categoriesMap)]; + const totalCount = Object.values(categoriesMap) + .reduce((n, c) => n + (c.tools?.length ?? 0), 0); + tabBar.replaceChildren(); + slugs.forEach((slug) => { + const tab = document.createElement('a'); + tab.className = 'tool-catalog-tab'; + tab.setAttribute('role', 'tab'); + tab.dataset.category = slug; + tab.href = slug === 'all' ? window.location.pathname : `${window.location.pathname}?category=${encodeURIComponent(slug)}`; + const isActive = slug === active; + tab.classList.toggle('is-active', isActive); + tab.setAttribute('aria-selected', isActive ? 'true' : 'false'); + const label = slug === 'all' ? 'All' : (categoriesMap[slug]?.label ?? slug); + const count = slug === 'all' ? totalCount : (categoriesMap[slug]?.tools?.length ?? 0); + tab.textContent = `${label} (${count})`; + tab.addEventListener('click', (e) => { + e.preventDefault(); + const url = new URL(window.location); + if (slug === 'all') url.searchParams.delete('category'); + else url.searchParams.set('category', slug); + window.history.pushState({}, '', url); + renderTabs(slug); + applyCategoryFilter(ul, slug === 'all' ? null : slug); + }); + tabBar.append(tab); + }); + } + observeLabStatus(ul); + + const initialSlug = parseCategoryFromUrl(window.location.href); + const renderAndApply = () => { + const slug = parseCategoryFromUrl(window.location.href) || 'all'; + renderTabs(slug); + applyCategoryFilter(ul, slug === 'all' ? null : slug); + }; + if (window.toolCategories) { + renderTabs(initialSlug || 'all'); + applyCategoryFilter(ul, initialSlug); + } else { + window.addEventListener('tools:categories-ready', renderAndApply, { once: true }); + } + window.addEventListener('popstate', renderAndApply); } diff --git a/docs/author-actions-ux-refresh.md b/docs/author-actions-ux-refresh.md new file mode 100644 index 00000000..31f6c2b6 --- /dev/null +++ b/docs/author-actions-ux-refresh.md @@ -0,0 +1,6 @@ +# Author actions to complete the UX refresh + +These are CMS / DA edits that complement the code changes in this PR. + +1. **Nav fragment cleanup**: in DA, edit the `/nav` fragment and remove the top-level `๐ Home` link. The catalog "All" tab supersedes it. +2. **Optional**: review the categories in `/nav`. The slugs derived from the labels are `setup-configure`, `publish-manage`, `dev-diagnostics`. If you change a category label, update the `` tag in each affected tool's `index.html` to match the new slug. diff --git a/fonts/RobotoFlex.woff2 b/fonts/RobotoFlex.woff2 deleted file mode 100644 index a5af6a36..00000000 Binary files a/fonts/RobotoFlex.woff2 and /dev/null differ diff --git a/fonts/adobe-clean-bold-italic.woff2 b/fonts/adobe-clean-bold-italic.woff2 new file mode 100644 index 00000000..3670330c Binary files /dev/null and b/fonts/adobe-clean-bold-italic.woff2 differ diff --git a/fonts/adobe-clean-bold.woff2 b/fonts/adobe-clean-bold.woff2 new file mode 100644 index 00000000..5520a33a Binary files /dev/null and b/fonts/adobe-clean-bold.woff2 differ diff --git a/fonts/adobe-clean-italic.woff2 b/fonts/adobe-clean-italic.woff2 new file mode 100644 index 00000000..2c715fc3 Binary files /dev/null and b/fonts/adobe-clean-italic.woff2 differ diff --git a/fonts/adobe-clean-regular.woff2 b/fonts/adobe-clean-regular.woff2 new file mode 100644 index 00000000..88842186 Binary files /dev/null and b/fonts/adobe-clean-regular.woff2 differ diff --git a/icons/S2_Icon_UserAvatar_20_N.svg b/icons/S2_Icon_UserAvatar_20_N.svg new file mode 100644 index 00000000..179db367 --- /dev/null +++ b/icons/S2_Icon_UserAvatar_20_N.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/icons/discord.svg b/icons/discord.svg new file mode 100644 index 00000000..c03e8e12 --- /dev/null +++ b/icons/discord.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/icons/s2-icon-contrast-20-n.svg b/icons/s2-icon-contrast-20-n.svg new file mode 100644 index 00000000..f5611c59 --- /dev/null +++ b/icons/s2-icon-contrast-20-n.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/styles/fonts.css b/styles/fonts.css index d3584d52..2f0dc013 100644 --- a/styles/fonts.css +++ b/styles/fonts.css @@ -1,10 +1,34 @@ /* stylelint-disable max-line-length */ @font-face { font-display: swap; - font-family: robotoflex; + font-family: adobe-clean; font-style: normal; - src: url('../fonts/RobotoFlex.woff2') format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + font-weight: 400; + src: url('../fonts/adobe-clean-regular.woff2') format('woff2'); +} + +@font-face { + font-display: swap; + font-family: adobe-clean; + font-style: italic; + font-weight: 400; + src: url('../fonts/adobe-clean-italic.woff2') format('woff2'); +} + +@font-face { + font-display: swap; + font-family: adobe-clean; + font-style: normal; + font-weight: 700; + src: url('../fonts/adobe-clean-bold.woff2') format('woff2'); +} + +@font-face { + font-display: swap; + font-family: adobe-clean; + font-style: italic; + font-weight: 700; + src: url('../fonts/adobe-clean-bold-italic.woff2') format('woff2'); } @font-face { diff --git a/styles/styles.css b/styles/styles.css index 7692b71a..9fb7b05c 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -45,7 +45,7 @@ --color-button-disabled-text: light-dark(var(--gray-400), var(--gray-500)); /* fonts */ - --body-font-family: robotoflex, robotoflex-fallback, sans-serif; + --body-font-family: 'Adobe Clean', adobe-clean, 'Trebuchet MS', sans-serif; --heading-font-family: var(--body-font-family); --code-font-family: robotomono, 'Courier New', monospace; @@ -89,11 +89,23 @@ html[data-theme="dark"] { } } -/* fallback fonts */ +/* fallback fonts โ Trebuchet MS with metrics adjusted to match Adobe Clean */ @font-face { - font-family: robotoflex-fallback; - size-adjust: 97.389%; - src: local('Arial'); + font-family: 'Trebuchet MS'; + src: local('Trebuchet MS'); + font-weight: 400; + size-adjust: 88%; + ascent-override: 92%; + descent-override: 18%; +} + +@font-face { + font-family: 'Trebuchet MS'; + src: local('Trebuchet MS Bold'), local('Trebuchet MS'); + font-weight: 700; + size-adjust: 90%; + ascent-override: 92%; + descent-override: 18%; } *, @@ -121,7 +133,7 @@ body[data-scroll='disabled'] { } header { - position: fixed; + position: sticky; top: 0; width: 100%; min-height: var(--header-height); @@ -134,9 +146,9 @@ header { main { max-width: 1200px; margin: 0 var(--horizontal-spacing); - padding: var(--header-height) 0; border-radius: var(--rounding-xl); background-color: var(--color-background); + padding: var(--spacing-s) 0; } @media (width >=900px) { @@ -149,6 +161,7 @@ main { @media (width >=1264px) { main { max-width: 1200px; + margin: 24px auto; } } @@ -382,6 +395,7 @@ h6 { h1 { font-size: var(--heading-size-xxl); + margin-top: 0; } h2 { diff --git a/tests/blocks/header/categories.test.js b/tests/blocks/header/categories.test.js new file mode 100644 index 00000000..9aedf898 --- /dev/null +++ b/tests/blocks/header/categories.test.js @@ -0,0 +1,83 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseCategories, slugify } from '../../../blocks/header/categories.js'; + +const fixture = ` + + + ๐ Home + Setup & Configure + + Admin Edit + Site Admin + + + Publish & Manage + + Bulk Operations + + + Dev & Diagnostics + + SVG Doctor + + + + ๐งช = Experimental tools from our labs + +`; + +describe('blocks/header/categories.js', () => { + describe('slugify', () => { + it('lowercases and dasherises labels', () => { + assert.equal(slugify('Setup & Configure'), 'setup-configure'); + assert.equal(slugify('Publish & Manage'), 'publish-manage'); + assert.equal(slugify('Dev & Diagnostics'), 'dev-diagnostics'); + }); + it('strips repeated dashes and trims', () => { + assert.equal(slugify(' Foo -- Bar '), 'foo-bar'); + }); + }); + + describe('parseCategories', () => { + it('returns ordered categories from a nav fragment', () => { + const cats = parseCategories(fixture); + assert.deepEqual(cats.map((c) => c.slug), ['setup-configure', 'publish-manage', 'dev-diagnostics']); + assert.deepEqual(cats.map((c) => c.label), ['Setup & Configure', 'Publish & Manage', 'Dev & Diagnostics']); + }); + + it('collects tools per category with author labels', () => { + const cats = parseCategories(fixture); + const setup = cats.find((c) => c.slug === 'setup-configure'); + assert.ok(Array.isArray(setup.tools)); + assert.equal(setup.tools.length, 2); + assert.deepEqual(setup.tools[0], { url: '/tools/admin-edit/index.html', label: 'Admin Edit' }); + assert.deepEqual(setup.tools[1], { url: '/tools/site-admin/index.html', label: 'Site Admin' }); + }); + + it('skips top-level links that are not categories (e.g. Home)', () => { + const cats = parseCategories(fixture); + assert.ok(!cats.some((c) => /home/i.test(c.label))); + }); + + it('returns an empty array for empty input', () => { + assert.deepEqual(parseCategories(''), []); + assert.deepEqual(parseCategories(''), []); + }); + + it('handles absolute and relative tool URLs', () => { + const html = ` + X + + Rel + Abs + + `; + const cats = parseCategories(html); + assert.equal(cats.length, 1); + const urls = cats[0].tools.map((t) => t.url); + assert.ok(urls.includes('/tools/relative/index.html')); + assert.ok(urls.includes('/tools/abs/index.html')); + }); + }); +}); diff --git a/tests/blocks/header/combobox-filter.test.js b/tests/blocks/header/combobox-filter.test.js new file mode 100644 index 00000000..ef7650cb --- /dev/null +++ b/tests/blocks/header/combobox-filter.test.js @@ -0,0 +1,39 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { filterItems } from '../../../blocks/header/combobox-filter.js'; + +describe('blocks/header/combobox-filter.js ยท filterItems', () => { + const items = ['adobe', 'Acme Corp', 'globex', 'Initech']; + + it('returns all items for an empty query, as a copy (not the same array)', () => { + const result = filterItems('', items); + assert.deepEqual(result, items); + assert.notStrictEqual(result, items); + }); + + it('matches case-insensitively on substring', () => { + assert.deepEqual(filterItems('ADOBE', items), ['adobe']); + assert.deepEqual(filterItems('corp', items), ['Acme Corp']); + }); + + it('returns an empty array when nothing matches', () => { + assert.deepEqual(filterItems('zzz', items), []); + }); + + it('matches a substring in the middle of a string', () => { + assert.deepEqual(filterItems('lob', items), ['globex']); + }); + + it('returns all items for a whitespace-only query', () => { + const result = filterItems(' ', items); + assert.deepEqual(result, items); + assert.notStrictEqual(result, items); + }); + + it('does not mutate the input array', () => { + const input = ['one', 'two', 'three']; + const copy = [...input]; + filterItems('two', input); + assert.deepEqual(input, copy); + }); +}); diff --git a/tests/blocks/tool-catalog/filter.test.js b/tests/blocks/tool-catalog/filter.test.js new file mode 100644 index 00000000..f0fd9691 --- /dev/null +++ b/tests/blocks/tool-catalog/filter.test.js @@ -0,0 +1,75 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { matchesCategory, matchesSearch, parseCategoryFromUrl } from '../../../blocks/tool-catalog/filter.js'; + +const map = { + 'setup-configure': { + label: 'Setup & Configure', + tools: [ + { url: '/tools/admin-edit/index.html', label: 'Admin Edit' }, + { url: '/tools/site-admin/index.html', label: 'Site Admin' }, + ], + }, + 'publish-manage': { + label: 'Publish & Manage', + tools: [ + { url: '/tools/bulk/index.html', label: 'Bulk Operations' }, + ], + }, +}; + +describe('blocks/tool-catalog/filter.js', () => { + describe('matchesCategory', () => { + it('treats "all" or null as match-everything', () => { + assert.equal(matchesCategory('/tools/admin-edit/index.html', 'all', map), true); + assert.equal(matchesCategory('/tools/admin-edit/index.html', null, map), true); + assert.equal(matchesCategory('/tools/admin-edit/index.html', '', map), true); + }); + it('matches when path is in the slug bucket', () => { + assert.equal(matchesCategory('/tools/admin-edit/index.html', 'setup-configure', map), true); + assert.equal(matchesCategory('/tools/bulk/index.html', 'publish-manage', map), true); + }); + it('does not match when path is not in the slug bucket', () => { + assert.equal(matchesCategory('/tools/bulk/index.html', 'setup-configure', map), false); + }); + it('returns false for unknown slug', () => { + assert.equal(matchesCategory('/tools/admin-edit/index.html', 'nope', map), false); + }); + it('normalizes /index.html and trailing slash equivalence', () => { + const m = { foo: { label: 'Foo', tools: [{ url: '/tools/x/', label: 'X' }] } }; + assert.equal(matchesCategory('/tools/x/index.html', 'foo', m), true); + assert.equal(matchesCategory('/tools/x', 'foo', m), true); + }); + }); + + describe('matchesSearch', () => { + it('matches everything for an empty or whitespace query', () => { + assert.equal(matchesSearch('Admin Edit', ''), true); + assert.equal(matchesSearch('Admin Edit', ' '), true); + assert.equal(matchesSearch('Admin Edit', undefined), true); + }); + it('matches case-insensitive substrings', () => { + assert.equal(matchesSearch('Admin Edit โ manage metadata', 'METADATA'), true); + assert.equal(matchesSearch('Bulk Operations', 'bulk'), true); + }); + it('does not match when the query is absent from the text', () => { + assert.equal(matchesSearch('Admin Edit', 'sitemap'), false); + }); + it('trims surrounding whitespace from the query', () => { + assert.equal(matchesSearch('Admin Edit', ' edit '), true); + }); + it('treats missing text as no match for a non-empty query', () => { + assert.equal(matchesSearch('', 'edit'), false); + assert.equal(matchesSearch(undefined, 'edit'), false); + }); + }); + + describe('parseCategoryFromUrl', () => { + it('reads ?category=', () => { + assert.equal(parseCategoryFromUrl('https://x/?category=publish-manage'), 'publish-manage'); + }); + it('returns null when missing', () => { + assert.equal(parseCategoryFromUrl('https://x/'), null); + }); + }); +}); diff --git a/tests/utils/admin-request.test.js b/tests/utils/admin-request.test.js index c192880f..30628eb7 100644 --- a/tests/utils/admin-request.test.js +++ b/tests/utils/admin-request.test.js @@ -7,15 +7,18 @@ import assert from 'node:assert/strict'; // Mock the profile and config modules before importing the helper. This is // one of the rare places where mocking is the right call (see TESTING.md): // the helper's only job IS to orchestrate ensureLogin + window events + -// updateConfig into a result, so the seam under test is the contract with +// updateStorage into a result, so the seam under test is the contract with // those collaborators. let ensureLoginStub; -let updateConfigCalls; +let updateStorageCalls; +let updateStorageArgs; mock.module('../../blocks/profile/profile.js', { namedExports: { ensureLogin: (...args) => ensureLoginStub(...args) }, }); mock.module('../../utils/config/config.js', { - namedExports: { updateConfig: () => { updateConfigCalls += 1; } }, + namedExports: { + updateStorage: (...args) => { updateStorageCalls += 1; updateStorageArgs = args; }, + }, }); const { executeAdminRequest, AuthMode } = await import('../../utils/admin-request.js'); @@ -65,7 +68,8 @@ before(() => setupWindow()); beforeEach(() => { ensureLoginStub = stubReturning(true); - updateConfigCalls = 0; + updateStorageCalls = 0; + updateStorageArgs = undefined; }); describe('executeAdminRequest', () => { @@ -201,17 +205,18 @@ describe('executeAdminRequest', () => { }); }); - describe('updateConfig persistence', () => { - it('runs after a successful request', async () => { + describe('updateStorage persistence', () => { + it('runs after a successful request with the auth org/site', async () => { ensureLoginStub = stubReturning(true); - await executeAdminRequest(requestFnReturning(200), { org: 'adobe' }); - assert.equal(updateConfigCalls, 1); + await executeAdminRequest(requestFnReturning(200), { org: 'adobe', site: 'blog' }); + assert.equal(updateStorageCalls, 1); + assert.deepEqual(updateStorageArgs, ['adobe', 'blog']); }); it('does not run on non-2xx responses (404, 500, etc) โ server didn\'t validate the org/site', async () => { ensureLoginStub = stubReturning(true); await executeAdminRequest(requestFnReturning(404), { org: 'adobe' }); - assert.equal(updateConfigCalls, 0); + assert.equal(updateStorageCalls, 0); }); it('does not run when the user cancels the preflight login', async () => { @@ -222,7 +227,7 @@ describe('executeAdminRequest', () => { await new Promise((r) => { queueMicrotask(r); }); dispatchProfile('cancelled'); await promise; - assert.equal(updateConfigCalls, 0); + assert.equal(updateStorageCalls, 0); }); it('does not run when the user cancels after a 401', async () => { @@ -231,7 +236,7 @@ describe('executeAdminRequest', () => { await new Promise((r) => { queueMicrotask(r); }); dispatchProfile('cancelled'); await promise; - assert.equal(updateConfigCalls, 0); + assert.equal(updateStorageCalls, 0); }); }); }); diff --git a/tests/utils/config.test.js b/tests/utils/config.test.js new file mode 100644 index 00000000..43736b61 --- /dev/null +++ b/tests/utils/config.test.js @@ -0,0 +1,32 @@ +import { + describe, it, beforeEach, afterEach, +} from 'node:test'; +import assert from 'node:assert/strict'; +import { getProjectFromUrl } from '../../utils/config/config.js'; + +describe('utils/config/config.js ยท getProjectFromUrl', () => { + let originalHref; + beforeEach(() => { originalHref = window.location.href; }); + afterEach(() => { window.history.replaceState({}, '', originalHref); }); + + it('returns org and site from current URL params', () => { + window.history.replaceState({}, '', '/?org=acme&site=blog'); + assert.deepEqual(getProjectFromUrl(), { org: 'acme', site: 'blog' }); + }); + + it('returns empty strings when both params are missing', () => { + window.history.replaceState({}, '', '/'); + assert.deepEqual(getProjectFromUrl(), { org: '', site: '' }); + }); + + it('handles one param missing', () => { + window.history.replaceState({}, '', '/?org=acme'); + assert.deepEqual(getProjectFromUrl(), { org: 'acme', site: '' }); + }); + + it('preserves other params (read-only)', () => { + window.history.replaceState({}, '', '/?org=acme&site=blog&path=/foo'); + assert.deepEqual(getProjectFromUrl(), { org: 'acme', site: 'blog' }); + assert.equal(new URL(window.location.href).searchParams.get('path'), '/foo'); + }); +}); diff --git a/tools/admin-edit/admin-edit.css b/tools/admin-edit/admin-edit.css index dbfe9b20..63b6949d 100644 --- a/tools/admin-edit/admin-edit.css +++ b/tools/admin-edit/admin-edit.css @@ -13,10 +13,6 @@ gap: var(--spacing-l); } - .admin-edit #admin-form .config-field { - grid-column: 1 / span 2; - } - .admin-edit #admin-form .url-field { margin-top: 0; } diff --git a/tools/admin-edit/index.html b/tools/admin-edit/index.html index abc5301c..588949dc 100644 --- a/tools/admin-edit/index.html +++ b/tools/admin-edit/index.html @@ -9,6 +9,7 @@ > + Admin Edit diff --git a/tools/bulk/index.html b/tools/bulk/index.html index ffe1b3f4..2945d78d 100644 --- a/tools/bulk/index.html +++ b/tools/bulk/index.html @@ -9,6 +9,7 @@ > + Bulk Operations diff --git a/tools/cdn-setup/cdn-setup.js b/tools/cdn-setup/cdn-setup.js index 7cd06cdb..08d8db6f 100644 --- a/tools/cdn-setup/cdn-setup.js +++ b/tools/cdn-setup/cdn-setup.js @@ -1,10 +1,8 @@ /* eslint-disable no-alert */ import { registerToolReady } from '../../scripts/scripts.js'; -import { initConfigField, updateConfig } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; +import { ensureLogin } from '../../blocks/profile/profile.js'; import { logResponse } from '../../blocks/console/console.js'; -import admin from '../../scripts/helix-admin.js'; -import { executeAdminRequest, AuthMode } from '../../utils/admin-request.js'; -import { getErrorMessage } from './utils.js'; const adminForm = document.getElementById('admin-form'); const cdnForm = document.getElementById('cdn-form'); @@ -14,9 +12,8 @@ const cdnTypeItems = document.querySelectorAll('.cdn-type-list li'); const validationResults = document.getElementById('validation-results'); const validateBtn = document.getElementById('validate'); const saveBtn = document.getElementById('save'); +const fetchBtn = document.getElementById('fetch'); const consoleBlock = document.querySelector('.console'); -const site = document.getElementById('site'); -const org = document.getElementById('org'); const host = document.getElementById('host'); let originalConfig; @@ -91,6 +88,84 @@ const CDN_FIELDS = { ], }; +const ERROR_MESSAGES = { + ENOTFOUND: 'Could not connect to CDN endpoint. Please verify the hostname is correct.', + ECONNREFUSED: 'Connection refused by CDN server. Please check your endpoint configuration.', + ETIMEDOUT: 'Request timed out. The CDN server may be unreachable.', + ECONNRESET: 'Connection was reset. Please try again.', + 400: 'Bad request. Please check your configuration.', + 401: 'Authentication failed. Please verify your credentials.', + 403: 'Access denied. Your credentials may not have the required permissions.', + 404: 'Resource not found. Please verify your configuration.', + 500: 'CDN server error. Please try again later.', +}; + +function parseBody(body) { + if (!body) return null; + if (typeof body === 'object') return body; + if (typeof body !== 'string') return null; + + try { + return JSON.parse(body); + } catch { + return { rawMessage: body }; + } +} + +function getErrorMessage(result) { + if (result.status === 'ok' || result.status === 'succeeded') { + return 'Validation successful'; + } + + if (result.status === 'unsupported') { + return typeof result.body === 'string' ? result.body : 'This operation is not supported'; + } + + if (result.statusCode) { + const statusKey = String(result.statusCode); + if (ERROR_MESSAGES[statusKey]) { + return ERROR_MESSAGES[statusKey]; + } + } + + const body = parseBody(result.body); + if (!body) { + return 'Validation failed'; + } + + if (body.code) { + const codeKey = String(body.code); + if (ERROR_MESSAGES[codeKey]) { + return ERROR_MESSAGES[codeKey]; + } + } + + if (body.msg) { + return `Validation failed: ${body.msg}`; + } + + if (body.errors && Array.isArray(body.errors) && body.errors.length > 0) { + const firstError = body.errors[0]; + if (firstError.message) { + return `Validation failed: ${firstError.message}`; + } + } + + if (body.message) { + return `Validation failed: ${body.message}`; + } + + if (body.error) { + return `Validation failed: ${body.error}`; + } + + if (body.rawMessage && result.statusCode && ERROR_MESSAGES[String(result.statusCode)]) { + return ERROR_MESSAGES[String(result.statusCode)]; + } + + return 'Validation failed. Check details for more information.'; +} + function getSelectedCdnType() { const selected = document.querySelector('input[name="cdn-type"]:checked'); return selected ? selected.value : ''; @@ -300,8 +375,9 @@ async function runValidation() { } async function saveConfig() { - if (!org.value || !site.value) { - alert('Please select an organization and site first'); + const { org, site } = getProjectFromUrl(); + if (!org || !site) { + alert('Select an org/site in the header to continue.'); return; } @@ -314,35 +390,45 @@ async function saveConfig() { return; } + if (!await ensureLogin(org, site)) { + window.addEventListener('profile-update', ({ detail: loginInfo }) => { + if (loginInfo.includes(org)) { + saveConfig(); + } + }, { once: true }); + return; + } + + const cdnUrl = `https://admin.hlx.page/config/${org}/sites/${site}/cdn/prod.json`; const cdnConfig = getFormData(); + saveBtn.disabled = true; try { - const result = await executeAdminRequest( - () => admin.config({ org: org.value, site: site.value }) - .select('cdn/prod.json') - .update(JSON.stringify(cdnConfig)), - { org: org.value, site: site.value }, - ); - if (!result) return; - const { method, url } = result.request; - logResponse(consoleBlock, result.status, [method, url, result.error]); + const resp = await fetch(cdnUrl, { + method: 'POST', + body: JSON.stringify(cdnConfig), + headers: { + 'content-type': 'application/json', + }, + }); + + await resp.text(); + logResponse(consoleBlock, resp.status, ['POST', cdnUrl, resp.headers.get('x-error') || '']); } finally { saveBtn.disabled = false; } } -async function init() { - await initConfigField(); - - // Update URL params when org or site changes - org.addEventListener('change', () => { - updateConfig(); - }); +function syncSubmitEnabled() { + const { org, site } = getProjectFromUrl(); + const ready = !!(org && site); + if (fetchBtn) fetchBtn.disabled = !ready; +} - site.addEventListener('change', () => { - updateConfig(); - }); +async function init() { + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); cdnTypeRadios.forEach((radio) => { radio.addEventListener('change', () => { @@ -362,35 +448,33 @@ async function init() { adminForm.addEventListener('submit', async (e) => { e.preventDefault(); - if (!org.value || !site.value) { - alert('Please select an organization and site first'); + const { org, site } = getProjectFromUrl(); + if (!org || !site) { + alert('Select an org/site in the header to continue.'); return; } - const result = await executeAdminRequest(async () => { - const [aggRes, siteRes] = await Promise.all([ - admin.config({ org: org.value }).select(`aggregated/${site.value}.json`).read(), - admin.config({ org: org.value, site: site.value }).read(), - ]); - [aggRes, siteRes].forEach((r) => { - const { method, url } = r.request; - logResponse(consoleBlock, r.status, [method, url, r.error]); - }); - const status = aggRes.status === 401 || siteRes.status === 401 ? 401 : siteRes.status; - return { status, ok: siteRes.ok, parts: { aggRes, siteRes } }; - }, { org: org.value, site: site.value, policy: AuthMode.PREFLIGHT_AND_RETRY }); - - if (!result) return; - const { aggRes, siteRes } = result.parts; + if (!await ensureLogin(org, site)) { + window.addEventListener('profile-update', ({ detail: loginInfo }) => { + if (loginInfo.includes(org)) { + e.target.querySelector('button[type="submit"]').click(); + } + }, { once: true }); + return; + } + const aggregateConfig = await fetch(`https://admin.hlx.page/config/${org}/aggregated/${site}.json`); let aggConfig = {}; - if (aggRes.ok) { - const aggregate = await aggRes.json(); + if (aggregateConfig.ok) { + const aggregate = await aggregateConfig.json(); aggConfig = aggregate.cdn?.prod || {}; } - if (siteRes.status === 200) { - const siteConfig = await siteRes.json(); + const cdnUrl = `https://admin.hlx.page/config/${org}/sites/${site}.json`; + const resp = await fetch(cdnUrl); + + if (resp.status === 200) { + const siteConfig = await resp.json(); if (siteConfig.cdn && siteConfig.cdn.prod) { originalConfig = siteConfig.cdn.prod; host.value = originalConfig.host || ''; @@ -436,7 +520,7 @@ async function init() { validationPassed = false; validationResults.setAttribute('aria-hidden', 'true'); updateSaveButtonState(); - } else if (siteRes.status === 404) { + } else if (resp.status === 404) { originalConfig = {}; cdnForm.setAttribute('aria-hidden', 'false'); cdnForm.removeAttribute('disabled'); @@ -449,6 +533,8 @@ async function init() { cdnFields.innerHTML = ''; host.value = ''; } + + logResponse(consoleBlock, resp.status, ['GET', cdnUrl, resp.headers.get('x-error') || '']); }); host.addEventListener('input', () => { diff --git a/tools/cdn-setup/index.html b/tools/cdn-setup/index.html index 047ea530..b3c6f1bf 100644 --- a/tools/cdn-setup/index.html +++ b/tools/cdn-setup/index.html @@ -8,10 +8,10 @@ > + CDN Setup | AEM Tools - @@ -31,18 +31,6 @@ CDN Setup - - - Organization - - - - - Site - - - - Fetch diff --git a/tools/deep-psi/deep-psi.html b/tools/deep-psi/deep-psi.html index 96bc1d13..83c9def7 100644 --- a/tools/deep-psi/deep-psi.html +++ b/tools/deep-psi/deep-psi.html @@ -8,6 +8,7 @@ > + Deep PSI diff --git a/tools/error-analyzer/index.html b/tools/error-analyzer/index.html index 140b0cdf..7293548b 100644 --- a/tools/error-analyzer/index.html +++ b/tools/error-analyzer/index.html @@ -9,6 +9,7 @@ "@adobe/rum-distiller": "https://esm.sh/@adobe/rum-distiller@1.23.0" }} + Error Analyzer { headersList.append(createHeaderItem()); @@ -164,9 +170,10 @@ async function init() { headersForm.addEventListener('submit', async (e) => { e.preventDefault(); - if (!org.value || !site.value) { + const { org, site } = getProjectFromUrl(); + if (!org || !site) { // eslint-disable-next-line no-alert - alert('Please select an organization and site first'); + alert('Select an org/site in the header to continue.'); return; } @@ -179,12 +186,12 @@ async function init() { } }); - const headers = admin.config({ org: org.value, site: site.value }).select('headers.json'); + const headers = admin.config({ org, site }).select('headers.json'); const result = await executeAdminRequest( () => (Object.keys(patchedHeaders).length === 0 ? headers.remove() : headers.update(JSON.stringify(patchedHeaders))), - { org: org.value, site: site.value }, + { org, site }, ); if (!result) return; const { method, url } = result.request; @@ -193,16 +200,17 @@ async function init() { adminForm.addEventListener('submit', async (e) => { e.preventDefault(); - if (!org.value || !site.value) { + const { org, site } = getProjectFromUrl(); + if (!org || !site) { // eslint-disable-next-line no-alert - alert('Please select an organization and site first'); + alert('Select an org/site in the header to continue.'); return; } // Preflight on the fetch (entry point); the resulting session covers later saves. const result = await executeAdminRequest( - () => admin.config({ org: org.value, site: site.value }).select('headers.json').read(), - { org: org.value, site: site.value, policy: AuthMode.PREFLIGHT_AND_RETRY }, + () => admin.config({ org, site }).select('headers.json').read(), + { org, site, policy: AuthMode.PREFLIGHT_AND_RETRY }, ); if (!result) return; headersList.innerHTML = ''; diff --git a/tools/headers-edit/index.html b/tools/headers-edit/index.html index 271badbb..f7ba96e6 100644 --- a/tools/headers-edit/index.html +++ b/tools/headers-edit/index.html @@ -8,11 +8,11 @@ > + HTTP Headers Editor | AEM Tools - @@ -26,18 +26,6 @@ HTTP Headers Editor - - - Organization - - - - - Site - - - - Fetch diff --git a/tools/image-audit/index.html b/tools/image-audit/index.html index 604189cc..cd49e31c 100644 --- a/tools/image-audit/index.html +++ b/tools/image-audit/index.html @@ -30,12 +30,10 @@ Image Audit - - - Site, Sitemap or Query Index - - - + + Site, Sitemap or Query Index + + diff --git a/tools/image-audit/scripts.js b/tools/image-audit/scripts.js index 57a2baea..1cc231d3 100644 --- a/tools/image-audit/scripts.js +++ b/tools/image-audit/scripts.js @@ -2,7 +2,6 @@ /* eslint-disable class-methods-use-this */ import { buildModal, registerToolReady } from '../../scripts/scripts.js'; import { decorateIcons } from '../../scripts/aem.js'; -import { initConfigField } from '../../utils/config/config.js'; /* reporting utilities */ /** * Generates sorted array of audit report rows. @@ -583,7 +582,10 @@ function registerListeners(doc) { url, path, } = getFormData(form); - window.history.pushState({}, '', `${window.location.pathname}?url=${encodeURIComponent(url)}&path=${encodeURIComponent(path)}`); + const params = new URLSearchParams(window.location.search); + params.set('url', url); + params.set('path', path); + window.history.pushState({}, '', `${window.location.pathname}?${params.toString()}`); try { let sitemapUrls; @@ -711,7 +713,6 @@ function registerListeners(doc) { } async function init() { - await initConfigField(); const params = new URLSearchParams(window.location.search); if (params.has('url')) document.getElementById('url').value = decodeURIComponent(params.get('url')); if (params.has('path')) document.getElementById('path').value = decodeURIComponent(params.get('path')); diff --git a/tools/index-admin/index-admin.js b/tools/index-admin/index-admin.js index e0a78fd7..4424988b 100644 --- a/tools/index-admin/index-admin.js +++ b/tools/index-admin/index-admin.js @@ -1,5 +1,5 @@ import { registerToolReady } from '../../scripts/scripts.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { toClassName } from '../../scripts/aem.js'; import admin from '../../scripts/helix-admin.js'; import { executeAdminRequest, AuthMode } from '../../utils/admin-request.js'; @@ -7,8 +7,6 @@ import { logResponse } from '../../blocks/console/console.js'; import deriveReindexPaths from './utils.js'; const adminForm = document.getElementById('admin-form'); -const site = document.getElementById('site'); -const org = document.getElementById('org'); const consoleBlock = document.querySelector('.console'); const addIndexButton = document.getElementById('add-index'); const fetchButton = document.getElementById('fetch'); @@ -162,9 +160,10 @@ function displayIndexDetails(indexName, indexDef, newIndex = false) { await ensureYaml(); const yamlText = YAML.stringify(loadedIndices); + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( - () => admin.config({ org: org.value, site: site.value }).select('content/query.yaml').update(yamlText), - { org: org.value, site: site.value }, + () => admin.config({ org, site }).select('content/query.yaml').update(yamlText), + { org, site }, ); if (!result) return; const { method, url } = result.request; @@ -231,9 +230,10 @@ function showJobStatus(jobDetails) { } async function reIndex(indexNames, paths) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( - () => admin.index({ org: org.value, site: site.value }).update('/*', JSON.stringify({ paths, indexNames })), - { org: org.value, site: site.value }, + () => admin.index({ org, site }).update('/*', JSON.stringify({ paths, indexNames })), + { org, site }, ); if (!result) return { success: false }; const { method, url } = result.request; @@ -247,9 +247,10 @@ async function reIndex(indexNames, paths) { } async function fetchJobDetails(topic, name) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( - () => admin.job({ org: org.value, site: site.value }).get(`${topic}/${name}/details`), - { org: org.value, site: site.value }, + () => admin.job({ org, site }).get(`${topic}/${name}/details`), + { org, site }, ); if (!result) return null; const { method, url } = result.request; @@ -267,9 +268,10 @@ async function removeIndex(name) { await ensureYaml(); const yamlText = YAML.stringify(loadedIndices); + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( - () => admin.config({ org: org.value, site: site.value }).select('content/query.yaml').update(yamlText), - { org: org.value, site: site.value }, + () => admin.config({ org, site }).select('content/query.yaml').update(yamlText), + { org, site }, ); if (!result) return; const { method, url } = result.request; @@ -404,8 +406,15 @@ function populateIndexes(indexes) { }); } +function syncSubmitEnabled() { + const { org, site } = getProjectFromUrl(); + const ready = !!(org && site); + if (fetchButton) fetchButton.disabled = !ready; +} + async function init() { - await initConfigField(); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); addIndexButton.addEventListener('click', () => { displayIndexDetails('', { @@ -439,9 +448,10 @@ async function init() { adminForm.addEventListener('submit', async (e) => { e.preventDefault(); - if (!org.value || !site.value) { + const { org, site } = getProjectFromUrl(); + if (!org || !site) { // eslint-disable-next-line no-alert - alert('Please select an organization and site first'); + alert('Select an org/site in the header to continue.'); return; } @@ -452,8 +462,8 @@ async function init() { try { // Preflight on the fetch (entry point); the resulting session covers later saves. const result = await executeAdminRequest( - () => admin.config({ org: org.value, site: site.value }).select('content/query.yaml').read(), - { org: org.value, site: site.value, policy: AuthMode.PREFLIGHT_AND_RETRY }, + () => admin.config({ org, site }).select('content/query.yaml').read(), + { org, site, policy: AuthMode.PREFLIGHT_AND_RETRY }, ); if (!result) return; const { method, url } = result.request; diff --git a/tools/index-admin/index.html b/tools/index-admin/index.html index 80f2eaa6..4bbd16e7 100644 --- a/tools/index-admin/index.html +++ b/tools/index-admin/index.html @@ -6,12 +6,12 @@ content="script-src 'nonce-aem' 'strict-dynamic'; base-uri 'self'; object-src 'none';" move-to-http-header="true"> + Index Admin | AEM Tools - @@ -30,18 +30,6 @@ Index Admin - - - Organization - - - - - Site - - - - Fetch diff --git a/tools/json2html-simulator/index.html b/tools/json2html-simulator/index.html index 4ce7aee3..62afdbdf 100644 --- a/tools/json2html-simulator/index.html +++ b/tools/json2html-simulator/index.html @@ -9,6 +9,7 @@ > + JSON2HTML Simulator diff --git a/tools/log-viewer/index.html b/tools/log-viewer/index.html index a039dd67..537d300d 100644 --- a/tools/log-viewer/index.html +++ b/tools/log-viewer/index.html @@ -9,6 +9,7 @@ > + Log Viewer @@ -32,19 +33,6 @@ Log Viewer - - - Organization - - - - - Site - - - - - - - - Organization - - - - - Site - - - - - Fallback user email diff --git a/tools/media-library-backfill/media-library-backfill.css b/tools/media-library-backfill/media-library-backfill.css index 489c8c89..a88e1762 100644 --- a/tools/media-library-backfill/media-library-backfill.css +++ b/tools/media-library-backfill/media-library-backfill.css @@ -1,5 +1,3 @@ -@import url('../../utils/config/config.css'); - .media-library-backfill .options-field { margin-top: var(--spacing-l); } diff --git a/tools/media-library-backfill/media-library-backfill.js b/tools/media-library-backfill/media-library-backfill.js index cc207a7c..ae073ec0 100644 --- a/tools/media-library-backfill/media-library-backfill.js +++ b/tools/media-library-backfill/media-library-backfill.js @@ -1,6 +1,6 @@ import { registerToolReady } from '../../scripts/scripts.js'; import { ensureLogin } from '../../blocks/profile/profile.js'; -import { initConfigField, updateConfig } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import admin from '../../scripts/helix-admin.js'; import { executeAdminRequest, AuthMode } from '../../utils/admin-request.js'; import { @@ -92,8 +92,6 @@ const progressState = { function initDOM() { const form = document.getElementById('backfill-form'); DOM.form = form; - DOM.org = form.querySelector('#org'); - DOM.site = form.querySelector('#site'); DOM.fallbackUser = form.querySelector('#fallback-user'); DOM.runBtn = form.querySelector('#run-btn'); DOM.cancelBtn = form.querySelector('#cancel-btn'); @@ -373,12 +371,16 @@ function disableForm() { DOM.cancelBtn.disabled = false; } +function syncSubmitEnabled() { + const { org, site } = getProjectFromUrl(); + DOM.runBtn.disabled = !(org && site); +} + function enableForm() { resetLoadingButton(DOM.runBtn); [...DOM.form.elements].forEach((el) => { el.disabled = false; }); DOM.cancelBtn.hidden = true; - // site field should reflect org state - DOM.site.disabled = !DOM.org.value; + syncSubmitEnabled(); } function isAborted() { @@ -1980,11 +1982,13 @@ function showReport(startTime, exportResult) { } async function runBackfill() { - const org = DOM.org.value.trim(); - const site = DOM.site.value.trim(); + const { org, site } = getProjectFromUrl(); const fallbackUser = DOM.fallbackUser.value.trim(); - if (!org || !site) return; + if (!org || !site) { + log('Select an org/site in the header to continue.', 'error'); + return; + } if (!await ensureLogin(org, site)) { window.addEventListener('profile-update', ({ detail: loginInfo }) => { @@ -2185,7 +2189,6 @@ async function runBackfill() { if (isAborted()) return; showReport(startTime, exportResult); - updateConfig(); } catch (err) { if (err.name === 'AbortError') { log('Backfill cancelled by user.', 'warn'); @@ -2225,8 +2228,9 @@ function registerListeners() { async function init() { initDOM(); resetProgressTracking(); - await initConfigField(); registerListeners(); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); } registerToolReady(init()); diff --git a/tools/media-library/index.html b/tools/media-library/index.html index ced8781e..56a71b88 100644 --- a/tools/media-library/index.html +++ b/tools/media-library/index.html @@ -13,7 +13,6 @@ Media Library - @@ -30,21 +29,9 @@ Media Library - - - Organization - - - - - Site - - - - - Path (optional) - - + + Path (optional) + diff --git a/tools/media-library/media-library.css b/tools/media-library/media-library.css index 95a6d185..a7ce454f 100644 --- a/tools/media-library/media-library.css +++ b/tools/media-library/media-library.css @@ -151,19 +151,6 @@ body.media-library main > .workspace { margin-right: -50vw; } -body.media-library #media-library-form .config-field { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: var(--spacing-l); - width: 100%; -} - -@media (width < 600px) { - body.media-library #media-library-form .config-field { - grid-template-columns: 1fr; - } -} - body.media-library .media-library-config-bar { display: none; align-items: center; @@ -190,10 +177,6 @@ body.media-library .config-bar-label { color: light-dark(var(--gray-700), var(--gray-300)); } -body.media-library #media-library-form .config-field .form-field { - min-width: 0; -} - body.media-library #media-library-form .button-wrapper { margin: var(--spacing-200) 0 0; display: flex; diff --git a/tools/media-library/media-library.js b/tools/media-library/media-library.js index 2611ef9d..fa316a68 100644 --- a/tools/media-library/media-library.js +++ b/tools/media-library/media-library.js @@ -16,7 +16,7 @@ import t from './core/messages.js'; import { MediaLibraryError, ErrorCodes, logMediaLibraryError } from './core/errors.js'; import { ensureLogin } from '../../blocks/profile/profile.js'; import { registerToolReady } from '../../scripts/scripts.js'; -import { initConfigField, updateConfig } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { getMetadata, getMediaData, @@ -89,21 +89,19 @@ function syncFilterToUrl() { } async function init() { - const orgInput = document.getElementById('org'); - const siteInput = document.getElementById('site'); const pathInput = document.getElementById('path'); const workspace = document.getElementById('workspace'); const configEl = document.querySelector('.media-library-config'); const configBar = document.getElementById('config-bar'); const configBarChange = document.getElementById('config-bar-change'); - if (!orgInput || !siteInput) return; + const form = document.getElementById('media-library-form'); + if (!form) return; const searchParams = new URLSearchParams(window.location.search); const pathParam = searchParams.get('path'); const filterParam = searchParams.get('filter'); if (pathParam && pathInput) pathInput.value = pathParam; - await initConfigField(); mediaInfoModal = createMediaInfoModal(); await Promise.all([ @@ -187,8 +185,8 @@ async function init() { setMediaLibraryContext({ showMediaInfo: (opts) => mediaInfoModal?.show(opts), - getOrg: () => orgInput?.value, - getSite: () => siteInput?.value, + getOrg: () => getProjectFromUrl().org, + getSite: () => getProjectFromUrl().site, getPath: () => getPathFromInput(), }); @@ -458,14 +456,14 @@ async function init() { updateAppState({ isValidating: false }); } - const form = document.getElementById('media-library-form'); form?.addEventListener('submit', (e) => { e.preventDefault(); - const org = orgInput.value?.trim(); - const site = siteInput.value?.trim(); + const { org, site } = getProjectFromUrl(); const path = getPathFromInput(); - if (!org || !site) return; - updateConfig(); + if (!org || !site) { + updateAppState({ validationError: 'Select an org/site in the header to continue.' }); + return; + } const url = new URL(window.location.href); if (path) url.searchParams.set('path', path); else url.searchParams.delete('path'); @@ -540,18 +538,26 @@ async function init() { }); const loadBtn = document.getElementById('load-media'); + function syncSubmitEnabled() { + if (!loadBtn) return; + if (getAppState().isValidating === true) return; + const { org, site } = getProjectFromUrl(); + loadBtn.disabled = !(org && site); + } onStateChange(['isValidating'], (state) => { if (!loadBtn) return; const loading = state.isValidating === true; const textSpan = loadBtn.querySelector('.load-btn-text'); const loadingSpan = loadBtn.querySelector('.load-btn-loading'); - loadBtn.disabled = loading; if (textSpan) textSpan.hidden = loading; if (loadingSpan) loadingSpan.hidden = !loading; + if (loading) loadBtn.disabled = true; + else syncSubmitEnabled(); }); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); - const initialOrg = orgInput.value?.trim(); - const initialSite = siteInput.value?.trim(); + const { org: initialOrg, site: initialSite } = getProjectFromUrl(); const initialPath = getPathFromInput(); if (initialOrg && initialSite) { loadFromCache(initialOrg, initialSite, initialPath).then((hadCache) => { diff --git a/tools/page-status/diff.js b/tools/page-status/diff.js index b6404b8f..64eed8a3 100644 --- a/tools/page-status/diff.js +++ b/tools/page-status/diff.js @@ -1,5 +1,5 @@ import { registerToolReady } from '../../scripts/scripts.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { ensureLogin } from '../../blocks/profile/profile.js'; import escapeHtml from '../../utils/html.js'; @@ -867,8 +867,9 @@ async function loadSinglePageDiff(path) { async function init() { // Get params from URL const params = new URLSearchParams(window.location.search); - currentOrg = params.get('org'); - currentSite = params.get('site'); + const project = getProjectFromUrl(); + currentOrg = project.org; + currentSite = project.site; currentJob = params.get('job'); currentPath = params.get('path'); isEmbedMode = params.get('embed') === 'true'; @@ -879,13 +880,8 @@ async function init() { applyEmbedMode(); } - // Initialize config field only if not in embed mode - if (!isEmbedMode) { - await initConfigField(); - } - if (!currentOrg || !currentSite) { - showError('Missing org or site parameters. Use ?org=&site=&path= or &job='); + showError('Select an org/site in the header to continue.'); return; } diff --git a/tools/page-status/index.html b/tools/page-status/index.html index 6b84d02e..9146861b 100644 --- a/tools/page-status/index.html +++ b/tools/page-status/index.html @@ -8,6 +8,7 @@ > + Bulk Page Status Viewer @@ -28,19 +29,6 @@ Bulk Page Status - - - Organization - - - - - Site - - - - - Path diff --git a/tools/page-status/scripts.js b/tools/page-status/scripts.js index 6df71010..6734015a 100644 --- a/tools/page-status/scripts.js +++ b/tools/page-status/scripts.js @@ -1,10 +1,11 @@ import { registerToolReady } from '../../scripts/scripts.js'; import { decorateIcons } from '../../scripts/aem.js'; -import { initConfigField, updateConfig } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { ensureLogin } from '../../blocks/profile/profile.js'; import loadingMessages from './loading-messages.js'; const FORM = document.getElementById('status-form'); +const SUBMIT = FORM.querySelector('button[type="submit"]'); const TABLE = document.querySelector('table'); const CAPTION = TABLE.querySelector('caption'); const RESULTS = TABLE.querySelector('.results'); @@ -596,8 +597,7 @@ function downloadCSVFile(csvData) { async function runFromParams(search) { const params = new URLSearchParams(search); if (params && params.size > 0) { - const org = params.get('org'); - const site = params.get('site'); + const { org, site } = getProjectFromUrl(); const job = params.get('job'); if (org && site && job) { try { @@ -605,7 +605,6 @@ async function runFromParams(search) { setupJob(FORM, FORM.querySelector('button')); // fetch host config const { live, preview } = await validateHosts(org, site); - updateConfig(); // fetch page status and display results const jobUrl = `https://admin.hlx.page/job/${org}/${site}/main/status/${job}`; await runAndDisplayJob(jobUrl, live, preview); @@ -622,8 +621,31 @@ async function runFromParams(search) { } } +/** + * Displays a literal error message in the results table. + * @param {string} title - Error title text. + * @param {string} message - Error message text. + */ +function showTableMessage(title, message) { + const titleEl = ERROR.querySelector('strong'); + const messageEl = ERROR.querySelector('p:last-of-type'); + titleEl.textContent = title; + messageEl.textContent = message; + CAPTION.setAttribute('aria-hidden', true); + updateTableDisplay('error'); +} + +/** + * Enables the submit button only when an org/site is selected in the header. + */ +function syncSubmitEnabled() { + const { org, site } = getProjectFromUrl(); + if (SUBMIT) SUBMIT.disabled = !(org && site); +} + async function init() { - await initConfigField(); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); FORM.addEventListener('reset', () => { clearTable(RESULTS); @@ -633,8 +655,12 @@ async function init() { FORM.addEventListener('submit', async (e) => { e.preventDefault(); const { target, submitter } = e; + const { org, site } = getProjectFromUrl(); + if (!org || !site) { + showTableMessage('Error', 'Select an org/site in the header to continue.'); + return; + } const data = getFormData(target); - const { org, site } = data; if (!await ensureLogin(org, site)) { // not logged in yet, listen for profile-update event @@ -655,7 +681,6 @@ async function init() { const { path } = data; // fetch host config const { live, preview } = await validateHosts(org, site); - updateConfig(); // fetch page status and display results const jobUrl = await fetchJobUrl(org, site, path); if (!jobUrl) throw new Error('Failed to create page status job.'); @@ -715,8 +740,7 @@ async function init() { DIFFMODE.addEventListener('click', () => { if (DIFFMODE.disabled) return; - const data = getFormData(FORM); - const { org, site } = data; + const { org, site } = getProjectFromUrl(); if (!org || !site) return; // Get the job ID from the current URL params diff --git a/tools/page-status/styles.css b/tools/page-status/styles.css index 25b4d11e..ff350cc3 100644 --- a/tools/page-status/styles.css +++ b/tools/page-status/styles.css @@ -2,17 +2,13 @@ .page-status form#status-form { display: grid; grid-template: - 'config path' auto + 'path path' auto 'checkbox checkbox' auto 'button button' 1fr / 1fr 1fr; align-items: baseline; gap: 0 var(--spacing-l); } - .page-status form#status-form > .config-field { - grid-area: config; - } - .page-status form#status-form > .text-field { grid-area: path; } diff --git a/tools/pdp-scanner/index.html b/tools/pdp-scanner/index.html index c5ee097b..504328e0 100644 --- a/tools/pdp-scanner/index.html +++ b/tools/pdp-scanner/index.html @@ -12,7 +12,6 @@ PDP Scanner | AEM Tools - diff --git a/tools/robots-edit/index.html b/tools/robots-edit/index.html index 31c876f3..88629bc2 100644 --- a/tools/robots-edit/index.html +++ b/tools/robots-edit/index.html @@ -8,11 +8,11 @@ > + robots.txt Editor | AEM Tools - @@ -26,18 +26,6 @@ robots.txt Editor - - - Organization - - - - - Site - - - - Fetch diff --git a/tools/robots-edit/robots-edit.css b/tools/robots-edit/robots-edit.css index f29cc227..04ee61ae 100644 --- a/tools/robots-edit/robots-edit.css +++ b/tools/robots-edit/robots-edit.css @@ -12,10 +12,6 @@ grid-template-columns: 1fr max-content; gap: var(--spacing-l); } - - .robots-edit #admin-form .config-field { - grid-column: 1 / span 2; - } } .robots-edit #admin-form .button-wrapper { diff --git a/tools/robots-edit/robots-edit.js b/tools/robots-edit/robots-edit.js index 692f9c44..b6609ded 100644 --- a/tools/robots-edit/robots-edit.js +++ b/tools/robots-edit/robots-edit.js @@ -1,35 +1,42 @@ import { registerToolReady } from '../../scripts/scripts.js'; import admin from '../../scripts/helix-admin.js'; import { executeAdminRequest, AuthMode } from '../../utils/admin-request.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { logResponse } from '../../blocks/console/console.js'; const adminForm = document.getElementById('admin-form'); const bodyForm = document.getElementById('body-form'); const body = document.getElementById('body'); const consoleBlock = document.querySelector('.console'); -const site = document.getElementById('site'); -const org = document.getElementById('org'); +const fetchBtn = document.getElementById('fetch'); function logResult(result) { const { method, url } = result.request; logResponse(consoleBlock, result.status, [method, url, result.error]); } +function syncSubmitEnabled() { + const { org, site } = getProjectFromUrl(); + const ready = !!(org && site); + if (fetchBtn) fetchBtn.disabled = !ready; +} + async function init() { - await initConfigField(); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); bodyForm.addEventListener('submit', async (e) => { e.preventDefault(); - if (!org.value || !site.value) { + const { org, site } = getProjectFromUrl(); + if (!org || !site) { // eslint-disable-next-line no-alert - alert('Please select an organization and site first'); + alert('Select an org/site in the header to continue.'); return; } const result = await executeAdminRequest( - () => admin.config({ org: org.value, site: site.value }).select('robots.txt').update(body.value), - { org: org.value, site: site.value }, + () => admin.config({ org, site }).select('robots.txt').update(body.value), + { org, site }, ); if (!result) return; // 401 followed by cancelled login logResult(result); @@ -37,17 +44,18 @@ async function init() { adminForm.addEventListener('submit', async (e) => { e.preventDefault(); - if (!org.value || !site.value) { + const { org, site } = getProjectFromUrl(); + if (!org || !site) { // eslint-disable-next-line no-alert - alert('Please select an organization and site first'); + alert('Select an org/site in the header to continue.'); return; } // Preflight on Fetch (the natural entry point: fetch โ edit โ save). // Once the fetch succeeds, the user has an active session for any later save. const result = await executeAdminRequest( - () => admin.config({ org: org.value, site: site.value }).select('robots.txt').read(), - { org: org.value, site: site.value, policy: AuthMode.PREFLIGHT_AND_RETRY }, + () => admin.config({ org, site }).select('robots.txt').read(), + { org, site, policy: AuthMode.PREFLIGHT_AND_RETRY }, ); if (!result) return; // user cancelled login or timed out if (result.ok) body.value = await result.text(); diff --git a/tools/simple-config-editor/index.html b/tools/simple-config-editor/index.html index 5eb7490a..0756d6bf 100644 --- a/tools/simple-config-editor/index.html +++ b/tools/simple-config-editor/index.html @@ -9,6 +9,7 @@ > + Simple Config Editor @@ -32,21 +33,8 @@ Simple Config Editor - - - Organization - - - - - Site - - - - Load Config - Reset diff --git a/tools/simple-config-editor/simple-config-editor.js b/tools/simple-config-editor/simple-config-editor.js index 3e96202f..dc919c88 100644 --- a/tools/simple-config-editor/simple-config-editor.js +++ b/tools/simple-config-editor/simple-config-editor.js @@ -1,6 +1,6 @@ import { registerToolReady } from '../../scripts/scripts.js'; import { logResponse, logMessage } from '../../blocks/console/console.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { createModal } from '../../blocks/modal/modal.js'; import admin from '../../scripts/helix-admin.js'; import { executeAdminRequest, AuthMode } from '../../utils/admin-request.js'; @@ -87,8 +87,6 @@ const CDN_FIELDS = { ], }; -const org = document.getElementById('org'); -const site = document.getElementById('site'); const configEditor = document.getElementById('config-editor'); const configTbody = document.getElementById('config-tbody'); const consoleBlock = document.querySelector('.console'); @@ -919,10 +917,9 @@ async function performMigration(configToMigrate) { * 401 retries both. PREFLIGHT_AND_RETRY because this is the entry point. */ async function loadConfig() { - const orgVal = org.value; - const siteVal = site.value; + const { org: orgVal, site: siteVal } = getProjectFromUrl(); if (!orgVal || !siteVal) { - logMessage(consoleBlock, 'error', ['LOAD', 'Please select both organization and site', '']); + logMessage(consoleBlock, 'error', ['LOAD', 'Select an org/site in the header to continue.', '']); return; } @@ -1075,12 +1072,19 @@ function toggleInheritedProperties() { populateConfigTable(); } +function syncSubmitEnabled() { + const { org, site } = getProjectFromUrl(); + const ready = !!(org && site); + const loadBtn = document.getElementById('load-config'); + if (loadBtn) loadBtn.disabled = !ready; +} + /** * Initializes the config editor */ async function init() { - // Initialize config field (handles URL params, localStorage, sidekick auto-population) - await initConfigField(); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); // Load config when form is submitted. Auth is handled inside loadConfig // via executeAdminRequest with PREFLIGHT_AND_RETRY. @@ -1103,7 +1107,8 @@ const initPromise = init(); initPromise.then(async () => { // Auto-load config if both org and site are set from URL params - if (org.value && site.value) { + const { org, site } = getProjectFromUrl(); + if (org && site) { logMessage(consoleBlock, 'info', ['AUTO-LOAD', 'Auto-loading configuration from URL parameters', '']); await loadConfig(); } diff --git a/tools/site-admin/index.html b/tools/site-admin/index.html index 5999d09b..1d1a6bef 100644 --- a/tools/site-admin/index.html +++ b/tools/site-admin/index.html @@ -9,6 +9,7 @@ > + Site Admin @@ -24,36 +25,15 @@ Site Admin - Manage the sites of an org, or a single site you have access to. + Manage the sites of an org. - - - Organization - - - - - Make sure you are logged into sidekick on a project of the Organization selected - - - - - Site (optional) - - - - - - - List - Reset diff --git a/tools/site-admin/site-admin.js b/tools/site-admin/site-admin.js index a6f16c4d..6bbb108e 100644 --- a/tools/site-admin/site-admin.js +++ b/tools/site-admin/site-admin.js @@ -1,9 +1,9 @@ import { registerToolReady } from '../../scripts/scripts.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { logResponse } from '../../blocks/console/console.js'; import { ensureLogin } from '../../blocks/profile/profile.js'; import { VIEW_STORAGE_KEY } from './helpers/constants.js'; -import { fetchSites, getSitePath, adminFetch } from './helpers/api-helper.js'; +import { fetchSites, fetchSiteDetails } from './helpers/api-helper.js'; import { loadIcon, icon, @@ -14,107 +14,93 @@ import { import { openAddSiteModal } from './helpers/modals.js'; import createSiteCard from './helpers/site-card.js'; -const org = document.getElementById('org'); -const site = document.getElementById('site'); +const adminForm = document.getElementById('site-admin-form'); +const submitButton = adminForm.querySelector('button[type="submit"]'); const consoleBlock = document.querySelector('.console'); const sitesElem = document.querySelector('div#sites'); -const siteNotFoundHint = document.querySelector('.site-not-found-hint'); // Logging wrapper for API calls const logFn = (status, details) => logResponse(consoleBlock, status, details); -const hideSiteNotFoundHint = () => { - siteNotFoundHint.hidden = true; - siteNotFoundHint.textContent = ''; -}; - -const showSiteNotFoundHint = (siteName, orgValue) => { - siteNotFoundHint.textContent = `Site "${siteName}" not found in Organization "${orgValue}"`; - siteNotFoundHint.hidden = false; -}; - -const applyDetailsToCard = (card, details) => { - const contentUrl = details.content?.source?.url || ''; - const contentSourceType = details.content?.source?.type || ''; - const codeUrl = details.code?.source?.url || ''; - const sourceType = getContentSourceType(contentUrl, contentSourceType); - - const badge = card.querySelector('.source-badge'); - if (badge) { - badge.textContent = sourceType.label; - badge.className = `source-badge source-${sourceType.type}`; - badge.title = sourceType.type.toUpperCase(); - } +const populateCardDetails = (card, orgValue) => { + const siteName = card.dataset.site; + fetchSiteDetails(orgValue, siteName).then((details) => { + if (!details) return; + + const contentUrl = details.content?.source?.url || ''; + const contentSourceType = details.content?.source?.type || ''; + const codeUrl = details.code?.source?.url || ''; + const sourceType = getContentSourceType(contentUrl, contentSourceType); + + const badge = card.querySelector('.source-badge'); + if (badge) { + badge.textContent = sourceType.label; + badge.className = `source-badge source-${sourceType.type}`; + badge.title = sourceType.type.toUpperCase(); + } - const codeSource = card.querySelector('.site-card-source[data-type="code"]'); - const contentSource = card.querySelector('.site-card-source[data-type="content"]'); - const contentEditorUrl = getDAEditorURL(contentUrl); + const codeSource = card.querySelector('.site-card-source[data-type="code"]'); + const contentSource = card.querySelector('.site-card-source[data-type="content"]'); + const contentEditorUrl = getDAEditorURL(contentUrl); - if (codeSource) { - codeSource.title = codeUrl || 'Not configured'; - if (codeUrl) codeSource.href = codeUrl; - else codeSource.removeAttribute('href'); - } - if (contentSource) { - contentSource.title = contentUrl || 'Not configured'; - if (contentEditorUrl) contentSource.href = contentEditorUrl; - else contentSource.removeAttribute('href'); - } + if (codeSource) { + codeSource.title = codeUrl || 'Not configured'; + if (codeUrl) codeSource.href = codeUrl; + else codeSource.removeAttribute('href'); + } + if (contentSource) { + contentSource.title = contentUrl || 'Not configured'; + if (contentEditorUrl) contentSource.href = contentEditorUrl; + else contentSource.removeAttribute('href'); + } - card.dataset.codeUrl = codeUrl; - card.dataset.contentUrl = contentUrl; + card.dataset.codeUrl = codeUrl; + card.dataset.contentUrl = contentUrl; - const isByogit = details.code?.source?.type === 'byogit' - || codeUrl.includes('cm-repo.adobe.io'); - if (isByogit) { - card.dataset.byogitOwner = details.code?.source?.owner || details.code?.owner || ''; - card.dataset.byogitRepo = details.code?.source?.repo || details.code?.repo || ''; - } + const isByogit = details.code?.source?.type === 'byogit' + || codeUrl.includes('cm-repo.adobe.io'); + if (isByogit) { + card.dataset.byogitOwner = details.code?.source?.owner || details.code?.owner || ''; + card.dataset.byogitRepo = details.code?.source?.repo || details.code?.repo || ''; + } - const hasPreviewAuth = details.access?.site || details.access?.preview; - const hasLiveAuth = details.access?.site || details.access?.live; - const hasAnyAuth = hasPreviewAuth || hasLiveAuth; + const hasPreviewAuth = details.access?.site || details.access?.preview; + const hasLiveAuth = details.access?.site || details.access?.live; + const hasAnyAuth = hasPreviewAuth || hasLiveAuth; - const lighthouseBtn = card.querySelector('.menu-item[data-action="lighthouse"]'); - if (hasAnyAuth) { - card.dataset.hasAuth = 'true'; - if (lighthouseBtn) { - lighthouseBtn.disabled = true; - lighthouseBtn.title = 'Lighthouse unavailable for authenticated sites'; - } - if (hasPreviewAuth) { - card.querySelector('.auth-icon.auth-preview')?.removeAttribute('aria-hidden'); - } - if (hasLiveAuth) { - card.querySelector('.auth-icon.auth-live')?.removeAttribute('aria-hidden'); - } - } else { - delete card.dataset.hasAuth; - if (lighthouseBtn) { - lighthouseBtn.disabled = false; - lighthouseBtn.title = ''; + const lighthouseBtn = card.querySelector('.menu-item[data-action="lighthouse"]'); + if (hasAnyAuth) { + card.dataset.hasAuth = 'true'; + if (lighthouseBtn) { + lighthouseBtn.disabled = true; + lighthouseBtn.title = 'Lighthouse unavailable for authenticated sites'; + } + if (hasPreviewAuth) { + card.querySelector('.auth-icon.auth-preview').removeAttribute('aria-hidden'); + } + if (hasLiveAuth) { + card.querySelector('.auth-icon.auth-live').removeAttribute('aria-hidden'); + } + } else { + delete card.dataset.hasAuth; + if (lighthouseBtn) { + lighthouseBtn.disabled = false; + lighthouseBtn.title = ''; + } + card.querySelector('.auth-icon.auth-preview')?.setAttribute('aria-hidden', 'true'); + card.querySelector('.auth-icon.auth-live')?.setAttribute('aria-hidden', 'true'); } - card.querySelector('.auth-icon.auth-preview')?.setAttribute('aria-hidden', 'true'); - card.querySelector('.auth-icon.auth-live')?.setAttribute('aria-hidden', 'true'); - } - - const cdnHost = details.cdn?.prod?.host || details.cdn?.host; - const cdnEl = card.querySelector('.site-card-cdn'); - if (cdnHost && cdnEl) { - cdnEl.querySelector('span').textContent = cdnHost; - cdnEl.href = `https://${cdnHost}`; - cdnEl.classList.add('visible'); - } else if (cdnEl) { - cdnEl.classList.remove('visible'); - } -}; -const populateCardDetails = async (card, orgValue) => { - const siteName = card.dataset.site; - const resp = await adminFetch(getSitePath(orgValue, siteName), {}, logFn); - if (!resp.ok) return; - const details = await resp.json(); - applyDetailsToCard(card, details); + const cdnHost = details.cdn?.prod?.host || details.cdn?.host; + const cdnEl = card.querySelector('.site-card-cdn'); + if (cdnHost && cdnEl) { + cdnEl.querySelector('span').textContent = cdnHost; + cdnEl.href = `https://${cdnHost}`; + cdnEl.classList.add('visible'); + } else if (cdnEl) { + cdnEl.classList.remove('visible'); + } + }); }; const findCardBySite = (name) => sitesElem.querySelector(`.site-card[data-site="${CSS.escape(name)}"]`); @@ -127,11 +113,10 @@ const updateSiteCount = () => { let detailsObserver; -const displaySites = (sites, { limitedAccess = false } = {}) => { - sitesElem.removeAttribute('aria-hidden'); +const displaySites = (sites, orgValue) => { + sitesElem.ariaHidden = false; sitesElem.textContent = ''; - const selectedSite = new URLSearchParams(window.location.search).get('site'); const savedView = localStorage.getItem(VIEW_STORAGE_KEY) || 'grid'; const header = document.createElement('div'); @@ -139,59 +124,49 @@ const displaySites = (sites, { limitedAccess = false } = {}) => { header.innerHTML = ` ${sites.length} site${sites.length !== 1 ? 's' : ''} - ${sites.length > 1 ? ` - - - - - - ${icon('grid')} - - - ${icon('list')} - - - ` : ''} - ${limitedAccess ? '' : '+ Add Site'} + + + + + + ${icon('grid')} + + + ${icon('list')} + + + + Add Site `; - if (!limitedAccess) { - header.querySelector('.add-site-btn').addEventListener('click', () => openAddSiteModal(org.value, '', '', logFn)); - } + header.querySelector('.add-site-btn').addEventListener('click', () => openAddSiteModal(orgValue, '', '', logFn)); sitesElem.appendChild(header); const grid = document.createElement('div'); - grid.className = `sites-grid ${sites.length > 1 && savedView === 'list' ? 'list-view' : ''}`; - - if (sites.length > 1) { - const searchInput = header.querySelector('.search-input'); - searchInput.addEventListener('input', (e) => { - const query = e.target.value.toLowerCase().trim(); - grid.querySelectorAll('.site-card').forEach((card) => { - const siteName = card.dataset.site.toLowerCase(); - card.setAttribute('aria-hidden', !siteName.includes(query)); - }); + grid.className = `sites-grid ${savedView === 'list' ? 'list-view' : ''}`; + + const searchInput = header.querySelector('.search-input'); + searchInput.addEventListener('input', (e) => { + const query = e.target.value.toLowerCase().trim(); + grid.querySelectorAll('.site-card').forEach((card) => { + const siteName = card.dataset.site.toLowerCase(); + card.setAttribute('aria-hidden', !siteName.includes(query)); }); + }); - header.querySelectorAll('.view-btn').forEach((btn) => { - btn.addEventListener('click', () => { - const { view } = btn.dataset; - header.querySelectorAll('.view-btn').forEach((b) => b.classList.remove('active')); - btn.classList.add('active'); - grid.classList.toggle('list-view', view === 'list'); - localStorage.setItem(VIEW_STORAGE_KEY, view); - }); + header.querySelectorAll('.view-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const { view } = btn.dataset; + header.querySelectorAll('.view-btn').forEach((b) => b.classList.remove('active')); + btn.classList.add('active'); + grid.classList.toggle('list-view', view === 'list'); + localStorage.setItem(VIEW_STORAGE_KEY, view); }); - } + }); - const favorites = getFavorites(org.value); + const favorites = getFavorites(orgValue); const sortedSites = [...sites].sort((a, b) => { - if (selectedSite) { - if (a.name === selectedSite) return -1; - if (b.name === selectedSite) return 1; - } const aFav = favorites.includes(a.name); const bFav = favorites.includes(b.name); if (aFav && !bFav) return -1; @@ -203,69 +178,34 @@ const displaySites = (sites, { limitedAccess = false } = {}) => { detailsObserver = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { - populateCardDetails(entry.target, org.value); + populateCardDetails(entry.target, orgValue); detailsObserver.unobserve(entry.target); } }); }, { rootMargin: '200px' }); - sortedSites.forEach((s) => { - const card = createSiteCard(s, org.value, { limitedAccess }); + sortedSites.forEach((site) => { + const card = createSiteCard(site, orgValue); grid.appendChild(card); - if (s.details) { - applyDetailsToCard(card, s.details); - } else { - detailsObserver.observe(card); - } + detailsObserver.observe(card); }); sitesElem.appendChild(grid); - - if (selectedSite) { - const selectedCard = findCardBySite(selectedSite); - if (selectedCard) { - selectedCard.classList.add('selected'); - } else { - showSiteNotFoundHint(selectedSite, org.value); - } - } -}; - -const showAccessMessage = (text) => { - sitesElem.removeAttribute('aria-hidden'); - const msg = document.createElement('p'); - msg.className = 'access-message'; - msg.textContent = text; - sitesElem.appendChild(msg); }; const displaySitesForOrg = async (orgValue) => { sitesElem.setAttribute('aria-hidden', 'true'); sitesElem.replaceChildren(); - hideSiteNotFoundHint(); - const selectedSite = new URLSearchParams(window.location.search).get('site'); const { sites, status } = await fetchSites(orgValue, logFn); if (status === 200 && sites) { - displaySites(sites); + displaySites(sites, orgValue); } else if (status === 401) { const loggedIn = await ensureLogin(orgValue); if (loggedIn) { return displaySitesForOrg(orgValue); } - } else if (status === 403 && selectedSite) { - const siteResp = await adminFetch(getSitePath(orgValue, selectedSite), {}, logFn); - if (siteResp.ok) { - const details = await siteResp.json(); - displaySites([{ name: selectedSite, details }], { limitedAccess: true }); - } else if (siteResp.status === 403 || siteResp.status === 404) { - showAccessMessage(`Site "${selectedSite}" isn't available in org "${orgValue}". It may not exist, or you may not have access to it.`); - } else { - showAccessMessage(`Failed to load site "${selectedSite}" (HTTP ${siteResp.status}).`); - } - } else if (status === 403) { - showAccessMessage("You're not an admin of this org. Enter a site name above to manage just that site."); } return null; }; @@ -309,6 +249,11 @@ window.addEventListener('sites-refresh', (e) => { displaySitesForOrg(orgValue); }); +function syncSubmitEnabled() { + const { org } = getProjectFromUrl(); + submitButton.disabled = !org; +} + const initSiteAdmin = async () => { const neededIcons = [ 'code', 'document', 'edit', 'copy', 'external', 'trash', 'key', @@ -316,42 +261,30 @@ const initSiteAdmin = async () => { 'user', 'search', 'grid', 'list', 'star', ]; await Promise.all(neededIcons.map(loadIcon)); - await initConfigField(); - if (!org.value) org.value = localStorage.getItem('org') || 'adobe'; - const form = document.getElementById('site-admin-form'); - form.addEventListener('submit', async (e) => { + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); + + adminForm.addEventListener('submit', async (e) => { e.preventDefault(); - const { submitter } = e; - - const orgValue = org.value; - if (!orgValue) return; - - const url = new URL(window.location.href); - url.searchParams.set('org', orgValue); - const siteValue = site.value.trim() || ''; - if (siteValue) url.searchParams.set('site', siteValue); - else url.searchParams.delete('site'); - window.history.replaceState({}, document.title, url.href); - - const loggedIn = await ensureLogin(orgValue, siteValue || undefined); - if (!loggedIn) { - window.addEventListener('profile-update', ({ detail: loginInfo }) => { - if (loginInfo.includes(orgValue)) submitter?.click(); - }, { once: true }); + const { org } = getProjectFromUrl(); + if (!org) { + // eslint-disable-next-line no-alert + alert('Select an org in the header to continue.'); return; } - - displaySitesForOrg(orgValue); + const loggedIn = await ensureLogin(org); + if (loggedIn) { + displaySitesForOrg(org); + } }); - form.addEventListener('reset', hideSiteNotFoundHint); - - const params = new URLSearchParams(window.location.search); - if (params.get('org')) { - org.value = params.get('org'); - if (params.get('site')) site.value = params.get('site'); - document.getElementById('list').click(); + const { org } = getProjectFromUrl(); + if (org) { + const loggedIn = await ensureLogin(org); + if (loggedIn) { + displaySitesForOrg(org); + } } }; diff --git a/tools/site-query/index.html b/tools/site-query/index.html index ccd29eca..4f3d0739 100644 --- a/tools/site-query/index.html +++ b/tools/site-query/index.html @@ -4,6 +4,7 @@ + Site Query @@ -23,19 +24,6 @@ Site Query - - - Organization - - - - - Site - - - - - Query diff --git a/tools/site-query/scripts.js b/tools/site-query/scripts.js index eda563b1..510c95b0 100644 --- a/tools/site-query/scripts.js +++ b/tools/site-query/scripts.js @@ -1,5 +1,5 @@ import { registerToolReady } from '../../scripts/scripts.js'; -import { initConfigField, updateConfig } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { ensureLogin } from '../../blocks/profile/profile.js'; import admin from '../../scripts/helix-admin.js'; import { executeAdminRequest, AuthMode } from '../../utils/admin-request.js'; @@ -72,6 +72,11 @@ function clearResults(table) { function updateTableError(table, errCode, org, site) { const { title, msg } = (() => { switch (errCode) { + case 'project': + return { + title: 'No Project Selected', + msg: 'Select an org/site in the header to continue.', + }; case 401: ensureLogin(org, site); return { @@ -284,7 +289,6 @@ async function processUrl(sitemapUrl, query, queryType, org, site) { async function init(doc) { doc.querySelector('.site-query').dataset.status = 'loading'; - await initConfigField(); const form = doc.querySelector('#search-form'); const table = doc.querySelector('.table table'); @@ -293,8 +297,17 @@ async function init(doc) { const noResults = table.querySelector('tbody.no-results'); const stopButton = doc.querySelector('#stop-search'); const caption = table.querySelector('caption'); + const submitButton = form.querySelector('button[type="submit"]'); let stopped = false; + const syncSubmitEnabled = () => { + const { org, site } = getProjectFromUrl(); + const ready = !!(org && site); + if (submitButton) submitButton.disabled = !ready; + }; + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); + stopButton.addEventListener('click', () => { stopped = true; stopButton.setAttribute('aria-hidden', 'true'); @@ -304,13 +317,21 @@ async function init(doc) { form.addEventListener('submit', async (e) => { e.preventDefault(); + const { org, site } = getProjectFromUrl(); + if (!org || !site) { + updateTableError(table, 'project', org, site); + stopButton.setAttribute('aria-hidden', 'true'); + caption.setAttribute('aria-hidden', 'true'); + return; + } + stopped = false; results.setAttribute('aria-hidden', 'false'); error.setAttribute('aria-hidden', 'true'); noResults.setAttribute('aria-hidden', 'true'); const { - org, site, query, sitemap, queryType, path, + query, sitemap, queryType, path, } = getFormData(form); try { @@ -332,8 +353,6 @@ async function init(doc) { return; } - updateConfig(); - const sitemapUrls = sitemap.endsWith('.json') ? fetchQueryIndex(sitemap, live) : fetchSitemap(sitemap, live); let searched = 0; diff --git a/tools/sitemap-admin/index.html b/tools/sitemap-admin/index.html index 299efbef..f7abe420 100644 --- a/tools/sitemap-admin/index.html +++ b/tools/sitemap-admin/index.html @@ -6,12 +6,12 @@ content="script-src 'nonce-aem' 'strict-dynamic'; base-uri 'self'; object-src 'none';" move-to-http-header="true"> + Sitemap Admin | AEM Tools - @@ -30,18 +30,6 @@ Sitemap Admin - - - Organization - - - - - Site - - - - Fetch diff --git a/tools/sitemap-admin/sitemap-admin.js b/tools/sitemap-admin/sitemap-admin.js index b27e5388..e526750d 100644 --- a/tools/sitemap-admin/sitemap-admin.js +++ b/tools/sitemap-admin/sitemap-admin.js @@ -1,13 +1,12 @@ import { registerToolReady } from '../../scripts/scripts.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import admin from '../../scripts/helix-admin.js'; import { executeAdminRequest, AuthMode } from '../../utils/admin-request.js'; import { logResponse } from '../../blocks/console/console.js'; import { escapeXml, parseHreflang, collectSitemapEntries } from './utils.js'; const adminForm = document.getElementById('admin-form'); -const site = document.getElementById('site'); -const org = document.getElementById('org'); +const submitButton = adminForm.querySelector('button[type="submit"]'); const consoleBlock = document.querySelector('.console'); const addSitemapButton = document.getElementById('add-sitemap'); @@ -45,10 +44,11 @@ function logResult(result) { } async function saveSitemapYaml() { + const { org, site } = getProjectFromUrl(); const yamlText = YAML.stringify(loadedSitemaps); return executeAdminRequest( - () => admin.config({ org: org.value, site: site.value }).select('content/sitemap.yaml').update(yamlText), - { org: org.value, site: site.value }, + () => admin.config({ org, site }).select('content/sitemap.yaml').update(yamlText), + { org, site }, ); } @@ -354,9 +354,10 @@ async function removeLanguage(sitemapName, langCode) { } async function generateSitemap(destination) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( - () => admin.sitemap({ org: org.value, site: site.value }).update(destination), - { org: org.value, site: site.value }, + () => admin.sitemap({ org, site }).update(destination), + { org, site }, ); if (!result) return; const { method, url } = result.request; @@ -491,9 +492,10 @@ function populateSitemaps(sitemaps) { } async function fetchCdnProdHost() { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( - () => admin.config({ org: org.value, site: site.value }).select('cdn.json').read(), - { org: org.value, site: site.value }, + () => admin.config({ org, site }).select('cdn.json').read(), + { org, site }, ); if (!result) return; logResult(result); @@ -571,8 +573,15 @@ async function showIndexDialog() { }); } +function syncSubmitEnabled() { + const { org, site } = getProjectFromUrl(); + const ready = !!(org && site); + if (submitButton) submitButton.disabled = !ready; +} + async function init() { - await initConfigField(); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); addSitemapButton.addEventListener('click', () => { showTypeSelectionDialog(); @@ -580,16 +589,17 @@ async function init() { adminForm.addEventListener('submit', async (e) => { e.preventDefault(); - if (!org.value || !site.value) { + const { org, site } = getProjectFromUrl(); + if (!org || !site) { // eslint-disable-next-line no-alert - alert('Please select an organization and site first'); + alert('Select an org/site in the header to continue.'); return; } // Preflight on the fetch (entry point); the resulting session covers later saves. const result = await executeAdminRequest( - () => admin.config({ org: org.value, site: site.value }).select('content/sitemap.yaml').read(), - { org: org.value, site: site.value, policy: AuthMode.PREFLIGHT_AND_RETRY }, + () => admin.config({ org, site }).select('content/sitemap.yaml').read(), + { org, site, policy: AuthMode.PREFLIGHT_AND_RETRY }, ); if (!result) return; logResult(result); @@ -609,7 +619,8 @@ async function init() { } }); - if (org.value && site.value) { + const { org, site } = getProjectFromUrl(); + if (org && site) { adminForm.requestSubmit(); } } diff --git a/tools/snapshot-admin/index.html b/tools/snapshot-admin/index.html index 3ec433a4..1b3a55e1 100644 --- a/tools/snapshot-admin/index.html +++ b/tools/snapshot-admin/index.html @@ -8,6 +8,7 @@ > + Snapshot Admin @@ -27,21 +28,8 @@ Snapshot Admin - - - Organization - - - - - Site - - - - Load Snapshots - Reset diff --git a/tools/snapshot-admin/snapshot-admin.css b/tools/snapshot-admin/snapshot-admin.css index 5443e827..6814fa55 100644 --- a/tools/snapshot-admin/snapshot-admin.css +++ b/tools/snapshot-admin/snapshot-admin.css @@ -49,33 +49,6 @@ gap: var(--spacing-200); } -/* Config field styling for org/site fields */ -.form-field.config-field { - display: grid; -} - -@media (width >= 600px) { - .form-field.config-field { - grid-template-columns: 1fr 1fr; - gap: var(--spacing-l); - } - - .form-field.config-field .form-field { - margin-top: 0; - } - - .form-field.config-field .site-field { - position: relative; - } - - .form-field.config-field .site-field::before { - content: '/'; - position: absolute; - left: calc((var(--spacing-l) + 0.5ch) / -2); - bottom: calc(var(--border-m) + 0.4em); - } -} - /* Snapshot list */ .snapshots-container { margin-top: var(--spacing-500); diff --git a/tools/snapshot-admin/snapshot-admin.js b/tools/snapshot-admin/snapshot-admin.js index ba983374..54009107 100644 --- a/tools/snapshot-admin/snapshot-admin.js +++ b/tools/snapshot-admin/snapshot-admin.js @@ -1,4 +1,5 @@ import { registerToolReady } from '../../scripts/scripts.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { fetchSnapshots, saveManifest, @@ -7,8 +8,7 @@ import { // DOM Elements const sitePathForm = document.getElementById('site-path-form'); -const orgInput = document.getElementById('org'); -const siteInput = document.getElementById('site'); +const submitButton = sitePathForm.querySelector('button[type="submit"]'); const snapshotsContainer = document.getElementById('snapshots-container'); const snapshotsList = document.getElementById('snapshots-list'); const createSnapshotForm = document.getElementById('create-snapshot-form'); @@ -23,8 +23,6 @@ const modalClose = document.querySelector('.modal-close'); const modalOk = document.querySelector('.modal-ok'); const modalOverlay = document.querySelector('.modal-overlay'); -let currentOrg = ''; -let currentSite = ''; let snapshots = []; /** @@ -117,12 +115,13 @@ function logResponse(cols) { */ function createSnapshotCard(snapshot) { const { name } = snapshot; + const { org, site } = getProjectFromUrl(); return ` ${name} - Edit + Edit Delete @@ -249,36 +248,12 @@ async function createSnapshot(snapshotName) { } } -/** - * Handle org input changes to enable/disable site field - */ -orgInput.addEventListener('input', () => { - siteInput.disabled = !orgInput.value.trim(); -}); - -/** - * Handle org and site input changes to update currentOrg and currentSite - */ -siteInput.addEventListener('change', async () => { - if (orgInput.value.trim() && siteInput.value.trim()) { - currentOrg = orgInput.value.trim(); - currentSite = siteInput.value.trim(); - setOrgSite(currentOrg, currentSite); - } -}); - -/** - * Handle when config fields are populated programmatically (e.g., from sidekick) - */ -siteInput.addEventListener('input', async () => { - if (orgInput.value && siteInput.value && !currentOrg && !currentSite) { - currentOrg = orgInput.value.trim(); - currentSite = siteInput.value.trim(); - siteInput.disabled = false; - setOrgSite(currentOrg, currentSite); - await loadSnapshots(); - } -}); +function syncSubmitEnabled() { + const { org, site } = getProjectFromUrl(); + const ready = !!(org && site); + if (submitButton) submitButton.disabled = !ready; + setOrgSite(org, site); +} /** * Handle site path form submission @@ -286,29 +261,15 @@ siteInput.addEventListener('input', async () => { sitePathForm.addEventListener('submit', async (e) => { e.preventDefault(); - try { - const org = orgInput.value.trim(); - const site = siteInput.value.trim(); - - if (!org || !site) { - await showModal('Missing Information', 'Please enter both organization and site'); - return; - } - - currentOrg = org; - currentSite = site; - - const sitePath = `${org}/${site}`; - localStorage.setItem('snapshot-admin-site-path', sitePath); + const { org, site } = getProjectFromUrl(); + if (!org || !site) { + await showModal('Missing Information', 'Select an org/site in the header to continue.'); + return; + } + try { + setOrgSite(org, site); await loadSnapshots(); - - // Update URL - const url = new URL(window.location); - url.searchParams.set('org', org); - url.searchParams.set('site', site); - // eslint-disable-next-line no-restricted-globals - window.history.pushState({}, '', url); } catch (error) { await showModal('Error', `Error loading snapshots: ${error.message}`); } @@ -330,44 +291,22 @@ createSnapshotForm.addEventListener('submit', async (e) => { }); async function init() { - // Initialize from URL parameters or localStorage + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); + const params = new URLSearchParams(window.location.search); const snapshotParam = params.get('snapshot'); - const orgParam = params.get('org'); - const siteParam = params.get('site'); // Check if we have a snapshot URL parameter and send to snapshot-details.html if (snapshotParam) { window.location.href = `snapshot-details.html?snapshot=${snapshotParam}`; - } else if (orgParam && siteParam) { - // Use org and site parameters - orgInput.value = orgParam; - siteInput.value = siteParam; - currentOrg = orgParam; - currentSite = siteParam; - // Enable the site field since we have both org and site values - siteInput.disabled = false; - setOrgSite(currentOrg, currentSite); + return; + } + + const { org, site } = getProjectFromUrl(); + if (org && site) { + setOrgSite(org, site); await loadSnapshots(); - } else { - // No snapshot parameter, initialize config fields normally - try { - const { initConfigField } = await import('../../utils/config/config.js'); - await initConfigField(); - siteInput.disabled = false; - - // Check if config fields have values and set them up - if (orgInput.value && siteInput.value) { - currentOrg = orgInput.value; - currentSite = siteInput.value; - setOrgSite(currentOrg, currentSite); - await loadSnapshots(); - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to initialize config fields:', error); - // Continue loading the page even if config initialization fails - } } } diff --git a/tools/user-admin/index.html b/tools/user-admin/index.html index 01ee4962..7361c586 100644 --- a/tools/user-admin/index.html +++ b/tools/user-admin/index.html @@ -9,10 +9,10 @@ > + User Admin - @@ -31,24 +31,8 @@ User Admin - - - Organization - - - - - Site (optional) - - - - Leave blank to manage org-level users, or select a site to manage site-specific access. - - - Fetch Users - Reset diff --git a/tools/user-admin/user-admin.css b/tools/user-admin/user-admin.css index 4808f0fa..65d46e09 100644 --- a/tools/user-admin/user-admin.css +++ b/tools/user-admin/user-admin.css @@ -10,10 +10,6 @@ grid-template-columns: 1fr max-content; gap: var(--spacing-l); } - - .user-admin #admin-form .config-field { - grid-column: 1 / span 2; - } } .user-admin #admin-form .button-wrapper { @@ -524,10 +520,3 @@ dialog.user-admin-modal .add-another-btn { dialog.user-admin-modal.confirm-dialog { max-width: 400px; } - -@media (width >= 600px) { - .user-admin .form-field.config-field .site-field::before { - bottom: 50%; - transform: translateY(50%); - } -} diff --git a/tools/user-admin/user-admin.js b/tools/user-admin/user-admin.js index cb483a5d..1a935989 100644 --- a/tools/user-admin/user-admin.js +++ b/tools/user-admin/user-admin.js @@ -1,5 +1,5 @@ import { registerToolReady } from '../../scripts/scripts.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { logResponse } from '../../blocks/console/console.js'; import { loadIcon, icon, showToast } from '../../utils/card-ui/card-ui.js'; import admin from '../../scripts/helix-admin.js'; @@ -9,8 +9,7 @@ import { parseUsersFromAccessConfig, buildAccessConfig } from './utils.js'; const VIEW_STORAGE_KEY = 'user-admin-view'; const adminForm = document.getElementById('admin-form'); -const site = document.getElementById('site'); -const org = document.getElementById('org'); +const submitButton = adminForm.querySelector('button[type="submit"]'); const consoleBlock = document.querySelector('.console'); const usersContainer = document.getElementById('users-container'); const accessConfig = { type: 'org', users: [], originalSiteAccess: {} }; @@ -62,28 +61,30 @@ const ROLE_DESCRIPTIONS = { }; async function getOrgConfig() { + const { org } = getProjectFromUrl(); const result = await executeAdminRequest( async () => { - const res = await admin.config({ org: org.value }).read(); + const res = await admin.config({ org }).read(); logResponse(consoleBlock, res.status, [res.request.method, res.request.url, res.error]); return res; }, - { org: org.value, policy: AuthMode.PREFLIGHT_AND_RETRY }, + { org, policy: AuthMode.PREFLIGHT_AND_RETRY }, ); if (!result) return null; return result.ok ? result.json() : null; } async function getSiteAccessConfig() { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( async () => { - const res = await admin.config({ org: org.value, site: site.value }) + const res = await admin.config({ org, site }) .select('access.json') .read(); logResponse(consoleBlock, res.status, [res.request.method, res.request.url, res.error]); return res; }, - { org: org.value, site: site.value, policy: AuthMode.PREFLIGHT_AND_RETRY }, + { org, site, policy: AuthMode.PREFLIGHT_AND_RETRY }, ); if (!result) return null; if (result.status === 404) return { admin: { role: {} } }; @@ -91,30 +92,32 @@ async function getSiteAccessConfig() { } async function updateSiteAccess() { + const { org, site } = getProjectFromUrl(); const access = buildAccessConfig(accessConfig.originalSiteAccess, accessConfig.users); const result = await executeAdminRequest( async () => { - const res = await admin.config({ org: org.value, site: site.value }) + const res = await admin.config({ org, site }) .select('access.json') .update(JSON.stringify(access)); logResponse(consoleBlock, res.status, [res.request.method, res.request.url, res.error]); return res; }, - { org: org.value, site: site.value }, + { org, site }, ); return result?.ok ?? false; } async function updateOrgUserRoles(user) { + const { org } = getProjectFromUrl(); const result = await executeAdminRequest( async () => { - const res = await admin.config({ org: org.value }) + const res = await admin.config({ org }) .select(`users/${user.id}.json`) .update(JSON.stringify(user)); logResponse(consoleBlock, res.status, [res.request.method, res.request.url, res.error]); return res; }, - { org: org.value }, + { org }, ); return result?.ok ?? false; } @@ -125,15 +128,16 @@ async function deleteUserFromSite(user) { } async function deleteUserFromOrg(user) { + const { org } = getProjectFromUrl(); const result = await executeAdminRequest( async () => { - const res = await admin.config({ org: org.value }) + const res = await admin.config({ org }) .select(`users/${user.id}.json`) .remove(); logResponse(consoleBlock, res.status, [res.request.method, res.request.url, res.error]); return res; }, - { org: org.value }, + { org }, ); return result?.ok ?? false; } @@ -152,19 +156,20 @@ async function addUsersToSite(users) { } async function addUsersToOrg(users) { + const { org } = getProjectFromUrl(); let added = 0; try { await users.reduce(async (prevPromise, user) => { await prevPromise; const result = await executeAdminRequest( async () => { - const res = await admin.config({ org: org.value }) + const res = await admin.config({ org }) .select('users.json') .update(JSON.stringify(user)); logResponse(consoleBlock, res.status, [res.request.method, res.request.url, res.error]); return res; }, - { org: org.value }, + { org }, ); if (result?.ok) { added += 1; @@ -829,11 +834,23 @@ function displayUsers(users) { usersContainer.appendChild(grid); } +function syncSubmitEnabled() { + const { org } = getProjectFromUrl(); + if (submitButton) submitButton.disabled = !org; +} + adminForm.addEventListener('submit', async (e) => { e.preventDefault(); + + const { org, site } = getProjectFromUrl(); + if (!org) { + usersContainer.innerHTML = 'Select an org/site in the header to continue.'; + return; + } + usersContainer.innerHTML = 'Loading users...'; - if (site.value) { + if (site) { accessConfig.type = 'site'; const config = await getSiteAccessConfig(); if (!config) { @@ -862,9 +879,11 @@ async function init() { const neededIcons = ['user', 'edit', 'grid', 'list', 'trash']; await Promise.all(neededIcons.map(loadIcon)); - await initConfigField(); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); - if (org.value) { + const { org } = getProjectFromUrl(); + if (org) { adminForm.dispatchEvent(new Event('submit')); } } diff --git a/tools/version-admin/index.html b/tools/version-admin/index.html index d6013678..512574dd 100644 --- a/tools/version-admin/index.html +++ b/tools/version-admin/index.html @@ -9,11 +9,11 @@ > + Version Admin - @@ -39,22 +39,10 @@ Version Admin Profile - - - Organization - - - - - Site - - - - - Profile - - - + + Profile + + Fetch Versions Reset diff --git a/tools/version-admin/version-admin.css b/tools/version-admin/version-admin.css index e5ff2a56..9717a2bc 100644 --- a/tools/version-admin/version-admin.css +++ b/tools/version-admin/version-admin.css @@ -13,10 +13,6 @@ grid-template-columns: 1fr max-content; gap: var(--spacing-l); } - - .version-admin #admin-form .config-field { - grid-column: 1 / span 2; - } } #versions .button-wrapper { @@ -75,12 +71,6 @@ border-top: var(--border-s) solid var(--color-border); } -.version-admin .config-field { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: var(--spacing-m); -} - .version-admin .type-field { grid-column: 1 / -1; } diff --git a/tools/version-admin/version-admin.js b/tools/version-admin/version-admin.js index b09392a2..545f1df6 100644 --- a/tools/version-admin/version-admin.js +++ b/tools/version-admin/version-admin.js @@ -1,17 +1,14 @@ import { registerToolReady } from '../../scripts/scripts.js'; import { diffJson } from '../../vendor/diff/diff.js'; import { logResponse } from '../../blocks/console/console.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import admin from '../../scripts/helix-admin.js'; import { executeAdminRequest, AuthMode } from '../../utils/admin-request.js'; const adminForm = document.getElementById('admin-form'); const typeSelect = document.getElementById('type'); -const org = document.getElementById('org'); const profile = document.getElementById('profile'); -const site = document.getElementById('site'); const profileField = document.querySelector('.profile-field'); -const siteField = document.querySelector('.site-field'); const consoleBlock = document.querySelector('.console'); const versions = document.getElementById('versions'); const versionsTitle = document.getElementById('versions-title'); @@ -22,8 +19,9 @@ const fetchButton = document.getElementById('fetch'); const currentConfig = { type: '', versions: [], currentVersion: null }; function coords() { - const c = { org: org.value }; - if (typeSelect.value === 'site') c.site = site.value; + const { org, site } = getProjectFromUrl(); + const c = { org }; + if (typeSelect.value === 'site') c.site = site; else if (typeSelect.value === 'profile') c.profile = profile.value; return c; } @@ -40,9 +38,10 @@ function formatDate(dateString) { } async function fetchVersions() { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( () => admin.config(coords()).select('versions.json').read(), - { org: org.value, site: site.value, policy: AuthMode.PREFLIGHT_AND_RETRY }, + { org, site, policy: AuthMode.PREFLIGHT_AND_RETRY }, ); logResult(result); if (!result?.ok) return null; @@ -53,20 +52,22 @@ async function fetchVersions() { } async function fetchVersionData(versionId) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( () => admin.config(coords()).select(`versions/${versionId}.json`).read(), - { org: org.value, site: site.value }, + { org, site }, ); logResult(result); return result?.ok ? result.json() : null; } async function updateVersionName(versionId, newName) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( () => admin.config(coords()) .select(`versions/${versionId}.json`) .update(JSON.stringify({ name: newName }), { params: { name: newName } }), - { org: org.value, site: site.value }, + { org, site }, ); logResult(result); return !!result?.ok; @@ -74,18 +75,20 @@ async function updateVersionName(versionId, newName) { // POST to the config root with ?restoreVersion= and no body. async function restoreVersion(versionId) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( () => admin.config(coords()).update(null, { params: { restoreVersion: versionId } }), - { org: org.value, site: site.value }, + { org, site }, ); logResult(result); return !!result?.ok; } async function deleteVersion(versionId) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( () => admin.config(coords()).select(`versions/${versionId}.json`).remove(), - { org: org.value, site: site.value }, + { org, site }, ); logResult(result); return !!result?.ok; @@ -301,29 +304,22 @@ function updateFieldVisibility() { const selectedType = typeSelect.value; profileField.style.display = selectedType === 'profile' ? 'block' : 'none'; - siteField.style.display = selectedType === 'site' ? 'block' : 'none'; // Clear dependent fields when type changes if (selectedType !== 'profile') { profile.value = ''; } - if (selectedType !== 'site') { - site.value = ''; - } // Update required attributes profile.required = selectedType === 'profile'; - site.required = selectedType === 'site'; - - // Enable/disable site field based on type (for config utility compatibility) - site.disabled = selectedType !== 'site'; } /** * Validate form inputs */ function validateForm() { - if (!typeSelect.value || !org.value) { + const { org, site } = getProjectFromUrl(); + if (!typeSelect.value || !org) { return false; } @@ -331,19 +327,31 @@ function validateForm() { return false; } - if (typeSelect.value === 'site' && !site.value) { + if (typeSelect.value === 'site' && !site) { return false; } return true; } +function syncSubmitEnabled() { + const { org } = getProjectFromUrl(); + fetchButton.disabled = !org; +} + // Event listeners typeSelect.addEventListener('change', updateFieldVisibility); adminForm.addEventListener('submit', async (e) => { e.preventDefault(); + const { org, site } = getProjectFromUrl(); + if (!org || (typeSelect.value === 'site' && !site)) { + // eslint-disable-next-line no-alert + alert('Select an org/site in the header to continue.'); + return; + } + if (!validateForm()) { // eslint-disable-next-line no-alert alert('Please fill in all required fields.'); @@ -365,11 +373,11 @@ adminForm.addEventListener('submit', async (e) => { window.history.replaceState(null, '', url.href); // Update title - let titleText = `${org.value}`; + let titleText = `${org}`; if (typeSelect.value === 'profile') { titleText += ` / ${profile.value} (Profile)`; } else if (typeSelect.value === 'site') { - titleText += ` / ${site.value} (Site)`; + titleText += ` / ${site} (Site)`; } else { titleText += ' (Organization)'; } @@ -406,8 +414,8 @@ adminForm.addEventListener('submit', async (e) => { // Initialize async function init() { - // Initialize config field utility (handles org/site autofill from params/storage/sidekick) - await initConfigField(); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); // Initialize from URL parameters (type and profile are tool-specific) const params = new URLSearchParams(window.location.search); @@ -426,10 +434,11 @@ async function init() { } // Auto-submit if we have the required parameters - if (typeParam && org.value + const { org, site } = getProjectFromUrl(); + if (typeParam && org && ((typeParam === 'org') || (typeParam === 'profile' && profileParam) - || (typeParam === 'site' && site.value))) { + || (typeParam === 'site' && site))) { adminForm.dispatchEvent(new Event('submit')); } } diff --git a/utils/admin-request.js b/utils/admin-request.js index 1cbbddb3..f73c534f 100644 --- a/utils/admin-request.js +++ b/utils/admin-request.js @@ -1,5 +1,5 @@ import { ensureLogin } from '../blocks/profile/profile.js'; -import { updateConfig } from './config/config.js'; +import { updateStorage } from './config/config.js'; /** * Auth-handling policy for {@link executeAdminRequest}. @@ -32,9 +32,8 @@ function waitForLogin(org) { * Execute an admin API request with optional auth pre-check and 401 retry. * Returns `null` if the user fails to complete login. * - * `updateConfig()` only fires on 2xx โ a 4xx/5xx may not actually validate - * the org/site combo (e.g. 404 could mean the org doesn't exist). It's a - * no-op on pages without the org/site fields, so callers needn't opt in. + * Persistence to localStorage only fires on 2xx โ a 4xx/5xx may not actually + * validate the org/site combo (e.g. 404 could mean the org doesn't exist). * * @template {{ status: number }} T * @param {() => Promise} requestFn must be safe to invoke twice @@ -61,6 +60,6 @@ export async function executeAdminRequest(requestFn, authConfig) { result = await requestFn(); } - if (result?.ok) updateConfig(); + if (result?.ok) updateStorage(org, site); return result; } diff --git a/utils/config/config.css b/utils/config/config.css deleted file mode 100644 index 462b66d4..00000000 --- a/utils/config/config.css +++ /dev/null @@ -1,25 +0,0 @@ -.form-field.config-field { - display: grid; -} - -@media (width >= 600px) { - .form-field.config-field { - grid-template-columns: 1fr 1fr; - gap: var(--spacing-l); - } - - .form-field.config-field .form-field { - margin-top: 0; - } - - .form-field.config-field .site-field { - position: relative; - } - - .form-field.config-field .site-field::before { - content: '/'; - position: absolute; - left: calc((var(--spacing-l) + 0.5ch) / -2); - bottom: calc(var(--border-m) + 0.4em); - } -} diff --git a/utils/config/config.js b/utils/config/config.js index 84adf667..b827ef5d 100644 --- a/utils/config/config.js +++ b/utils/config/config.js @@ -1,95 +1,69 @@ -import { loadCSS } from '../../scripts/aem.js'; import { messageSidekick } from '../sidekick.js'; +const STORAGE_KEY = 'aem-projects'; + /** - * Validates the existence and type of config elements. - * @returns {Object|boolean} - Object containing config elements, or `false` if any are invalid. + * Reads the stored projects from localStorage. + * The `aem-projects` key has shape `{ orgs: string[], sites: { [org]: string[] } }`. + * @returns {{ orgs: string[], sitesByOrg: Record }} */ -function validDOM() { - const config = document.querySelector('.config-field'); - if (!config) return false; - - const params = { - org: { id: 'org', tag: 'INPUT' }, - orgList: { id: 'org-list', tag: 'DATALIST' }, - site: { id: 'site', tag: 'INPUT' }, - siteList: { id: 'site-list', tag: 'DATALIST' }, - }; +function cleanList(arr) { + return Array.isArray(arr) ? arr.filter((v) => typeof v === 'string' && v.trim() !== '') : []; +} - const els = {}; - Object.keys(params).forEach((key) => { - const { id, tag } = params[key]; - const el = config.querySelector(`#${id}`); - if (el && el.nodeName === tag) { - els[key] = el; +export function readStoredProjects() { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY)); + if (parsed && Array.isArray(parsed.orgs)) { + const orgs = cleanList(parsed.orgs); + const sitesByOrg = {}; + orgs.forEach((org) => { sitesByOrg[org] = cleanList(parsed.sites?.[org]); }); + return { orgs, sitesByOrg }; } - }); - - return Object.keys(els).length === Object.keys(params).length ? els : false; + } catch (e) { + // ignore parse errors and fall through to the default + } + return { orgs: [], sitesByOrg: {} }; } /** - * Sets field value and marks as autofilled (if it hasn't been autofilled already). - * @param {HTMLElement} field - Input field. - * @param {string} value - Value to set. - * @param {string} type - Type of autofill (params, storage, sidekick). - * @returns {string} the fields new value + * Merges sidekick-provided projects into the `aem-projects` localStorage entry. + * New orgs are appended; new sites are appended under their org. + * @param {{ org: string, site: string }[]} projects */ -function setFieldValue(field, value, type) { - if (!field.dataset.autofill) { - field.value = value; - field.dataset.autofill = type; - field.dispatchEvent(new Event('input')); +function mergeSidekickProjects(projects) { + let stored = null; + try { + stored = JSON.parse(localStorage.getItem(STORAGE_KEY)); + } catch (e) { + stored = null; } + if (!stored || !Array.isArray(stored.orgs)) { + stored = { orgs: [], sites: {} }; + } + if (!stored.sites) stored.sites = {}; - return field.value; -} - -/** - * Populates datalist with options (if options aren't already in datalist). - * @param {HTMLElement} list - Datalist element. - * @param {string[]} values - Array of values to populate as options. - */ -function populateList(list, values) { - values.forEach((value) => { - if (value && ![...list.options].some((o) => o.value === value)) { - const option = document.createElement('option'); - option.value = value; - list.append(option); - } + projects.forEach(({ org, site }) => { + if (!org) return; + if (!stored.orgs.includes(org)) stored.orgs.push(org); + if (!stored.sites[org]) stored.sites[org] = []; + if (site && !stored.sites[org].includes(site)) stored.sites[org].push(site); }); -} -/** - * Resets site datalist based on the selected org. - * @param {string} org - Organization name. - * @param {HTMLInputElement} site - Site input element. - * @param {HTMLDatalistElement} list - Site datalist element to populate with options. - */ -function resetSiteListForOrg(org, site, list) { - // clear site list - while (list.firstChild) list.removeChild(list.firstChild); - // repopulate site and site list from storage - const projects = JSON.parse(localStorage.getItem('aem-projects')); - if (projects && projects.sites && projects.sites[org]) { - site.value = projects.sites[org][0] || ''; - populateList(list, projects.sites[org]); - } + localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); } /** - * Updates URL params based on org/site data. - * @param {Array.} fields - Array of input fields. + * Loads the known org/site projects: reads localStorage, then merges in any projects + * reported by the AEM Sidekick extension (`getSites`), and returns the merged result. + * @returns {Promise<{ orgs: string[], sitesByOrg: Record }>} */ -function updateParams(fields) { - const url = new URL(window.location.href); - url.search = ''; // clear existing params - fields.forEach((field) => { - if (field.value) { - url.searchParams.set(field.id, field.value); - } - }); - window.history.replaceState({}, document.title, url.href); +export async function loadProjects() { + const sidekickProjects = await messageSidekick({ action: 'getSites' }); + if (Array.isArray(sidekickProjects) && sidekickProjects.length > 0) { + mergeSidekickProjects(sidekickProjects); + } + return readStoredProjects(); } /** @@ -97,8 +71,8 @@ function updateParams(fields) { * @param {string} org - Organization name. * @param {string} site - Site name within org. */ -function updateStorage(org, site) { - const projects = JSON.parse(localStorage.getItem('aem-projects')); +export function updateStorage(org, site) { + const projects = JSON.parse(localStorage.getItem(STORAGE_KEY)); if (projects) { // ensure org is most recent in orgs array if (projects.orgs.includes(org)) { @@ -116,154 +90,18 @@ function updateStorage(org, site) { projects.sites[org] = [site]; } } - localStorage.setItem('aem-projects', JSON.stringify(projects)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(projects)); } else { // init project org and site storage const project = { orgs: [org], sites: site ? { [org]: [site] } : {}, }; - localStorage.setItem('aem-projects', JSON.stringify(project)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(project)); } } -/** - * Adds all sidekick projects to storage data - * Since no order guarantee is made for sidekick projects, just add them at end of lists. - */ -function updateStorageFromSidekick(projects) { - let aemProjects = JSON.parse(localStorage.getItem('aem-projects')); - if (!aemProjects) { - aemProjects = { orgs: [], sites: {} }; - } - - projects.forEach(({ org, site }) => { - if (!aemProjects.orgs.includes(org)) { - aemProjects.orgs.push(org); - } - - if (!aemProjects.sites[org]) { - aemProjects.sites[org] = []; - } - - if (!aemProjects.sites[org].includes(site)) { - aemProjects.sites[org].push(site); - } - }); - - localStorage.setItem('aem-projects', JSON.stringify(aemProjects)); -} - -/** - * Updates URL parameters, local storage, and datalists based on org/site values. - */ -export function updateConfig() { - const cfg = validDOM(); - if (cfg) { - updateParams([cfg.org, cfg.site]); - updateStorage(cfg.org.value, cfg.site.value); - populateList(cfg.orgList, [cfg.org.value]); - populateList(cfg.siteList, [cfg.site.value]); - } -} - -/** - * Populates org/site fields with values from URL params. - * @param {Array.} fields - Array of input elements. - * @param {string} search - URL search string. - */ -function populateFromParams(fields, search) { - const params = new URLSearchParams(search); - if (params && params.size > 0) { - fields.forEach((field) => { - const param = params.get(field.id); - if (param) { - setFieldValue(field, param, 'params'); - } - }); - } -} - -/** - * Populates org and site fields from local storage. - */ -function populateFromStorage(org, orgList, site, siteList) { - const projects = JSON.parse(localStorage.getItem('aem-projects')); - if (projects) { - if (projects.orgs && projects.orgs[0]) { - // populate org list - const { orgs } = projects; - populateList(orgList, orgs); - // populate org field - const selectedOrg = setFieldValue(org, projects.orgs[0], 'storage'); - if (projects.sites && projects.sites[selectedOrg]) { - // populate site list - const sites = projects.sites[selectedOrg]; - populateList(siteList, sites); - // populate site field - const lastSite = sites[0]; - if (lastSite) setFieldValue(site, lastSite, 'storage'); - } - } - } -} - -/** - * Populates org field from sidekick. - */ -async function populateFromSidekick(org, orgList, site, siteList) { - const projects = await messageSidekick({ action: 'getSites' }); - if (Array.isArray(projects) && projects.length > 0) { - updateStorageFromSidekick(projects); - - const { orgs, sites } = projects.reduce((acc, part) => { - if (!acc.orgs.includes(part.org)) acc.orgs.push(part.org); - if (!acc.sites[part.org]) acc.sites[part.org] = []; - if (!acc.sites[part.org].includes(part.site)) acc.sites[part.org].push(part.site); - - return acc; - }, { orgs: [], sites: {} }); - - // populate org list - populateList(orgList, orgs); - - // populate org & site field - const lastProject = projects[0]; - const selectedOrg = setFieldValue(org, lastProject.org, 'sidekick'); - - populateList(siteList, sites[selectedOrg] || []); - if (sites[selectedOrg] && sites[selectedOrg][0]) { - setFieldValue(site, sites[selectedOrg][0], 'sidekick'); - } - } -} - -async function populateConfig(config) { - const { - org, orgList, site, siteList, - } = config; - populateFromParams([org, site], window.location.search); - populateFromStorage(org, orgList, site, siteList); - await populateFromSidekick(org, orgList, site, siteList); -} - -/** - * Loads org/site config CSS and initializes config field datalists/values. - */ -export async function initConfigField() { - const cfg = validDOM(); - - if (cfg) { - // enable site when org has value - cfg.org.addEventListener('input', () => { - cfg.site.disabled = !cfg.org.value; - }, { once: true }); - - // refresh site datalist to match org - cfg.org.addEventListener('change', (e) => { - resetSiteListForOrg(e.target.value, cfg.site, cfg.siteList); - }); - - await Promise.all([loadCSS('../../utils/config/config.css'), populateConfig(cfg)]); - } +export function getProjectFromUrl() { + const params = new URLSearchParams(window.location.search); + return { org: params.get('org') || '', site: params.get('site') || '' }; }
๐งช = Experimental tools from our labs
Fetch
diff --git a/tools/media-library/media-library.css b/tools/media-library/media-library.css index 95a6d185..a7ce454f 100644 --- a/tools/media-library/media-library.css +++ b/tools/media-library/media-library.css @@ -151,19 +151,6 @@ body.media-library main > .workspace { margin-right: -50vw; } -body.media-library #media-library-form .config-field { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: var(--spacing-l); - width: 100%; -} - -@media (width < 600px) { - body.media-library #media-library-form .config-field { - grid-template-columns: 1fr; - } -} - body.media-library .media-library-config-bar { display: none; align-items: center; @@ -190,10 +177,6 @@ body.media-library .config-bar-label { color: light-dark(var(--gray-700), var(--gray-300)); } -body.media-library #media-library-form .config-field .form-field { - min-width: 0; -} - body.media-library #media-library-form .button-wrapper { margin: var(--spacing-200) 0 0; display: flex; diff --git a/tools/media-library/media-library.js b/tools/media-library/media-library.js index 2611ef9d..fa316a68 100644 --- a/tools/media-library/media-library.js +++ b/tools/media-library/media-library.js @@ -16,7 +16,7 @@ import t from './core/messages.js'; import { MediaLibraryError, ErrorCodes, logMediaLibraryError } from './core/errors.js'; import { ensureLogin } from '../../blocks/profile/profile.js'; import { registerToolReady } from '../../scripts/scripts.js'; -import { initConfigField, updateConfig } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { getMetadata, getMediaData, @@ -89,21 +89,19 @@ function syncFilterToUrl() { } async function init() { - const orgInput = document.getElementById('org'); - const siteInput = document.getElementById('site'); const pathInput = document.getElementById('path'); const workspace = document.getElementById('workspace'); const configEl = document.querySelector('.media-library-config'); const configBar = document.getElementById('config-bar'); const configBarChange = document.getElementById('config-bar-change'); - if (!orgInput || !siteInput) return; + const form = document.getElementById('media-library-form'); + if (!form) return; const searchParams = new URLSearchParams(window.location.search); const pathParam = searchParams.get('path'); const filterParam = searchParams.get('filter'); if (pathParam && pathInput) pathInput.value = pathParam; - await initConfigField(); mediaInfoModal = createMediaInfoModal(); await Promise.all([ @@ -187,8 +185,8 @@ async function init() { setMediaLibraryContext({ showMediaInfo: (opts) => mediaInfoModal?.show(opts), - getOrg: () => orgInput?.value, - getSite: () => siteInput?.value, + getOrg: () => getProjectFromUrl().org, + getSite: () => getProjectFromUrl().site, getPath: () => getPathFromInput(), }); @@ -458,14 +456,14 @@ async function init() { updateAppState({ isValidating: false }); } - const form = document.getElementById('media-library-form'); form?.addEventListener('submit', (e) => { e.preventDefault(); - const org = orgInput.value?.trim(); - const site = siteInput.value?.trim(); + const { org, site } = getProjectFromUrl(); const path = getPathFromInput(); - if (!org || !site) return; - updateConfig(); + if (!org || !site) { + updateAppState({ validationError: 'Select an org/site in the header to continue.' }); + return; + } const url = new URL(window.location.href); if (path) url.searchParams.set('path', path); else url.searchParams.delete('path'); @@ -540,18 +538,26 @@ async function init() { }); const loadBtn = document.getElementById('load-media'); + function syncSubmitEnabled() { + if (!loadBtn) return; + if (getAppState().isValidating === true) return; + const { org, site } = getProjectFromUrl(); + loadBtn.disabled = !(org && site); + } onStateChange(['isValidating'], (state) => { if (!loadBtn) return; const loading = state.isValidating === true; const textSpan = loadBtn.querySelector('.load-btn-text'); const loadingSpan = loadBtn.querySelector('.load-btn-loading'); - loadBtn.disabled = loading; if (textSpan) textSpan.hidden = loading; if (loadingSpan) loadingSpan.hidden = !loading; + if (loading) loadBtn.disabled = true; + else syncSubmitEnabled(); }); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); - const initialOrg = orgInput.value?.trim(); - const initialSite = siteInput.value?.trim(); + const { org: initialOrg, site: initialSite } = getProjectFromUrl(); const initialPath = getPathFromInput(); if (initialOrg && initialSite) { loadFromCache(initialOrg, initialSite, initialPath).then((hadCache) => { diff --git a/tools/page-status/diff.js b/tools/page-status/diff.js index b6404b8f..64eed8a3 100644 --- a/tools/page-status/diff.js +++ b/tools/page-status/diff.js @@ -1,5 +1,5 @@ import { registerToolReady } from '../../scripts/scripts.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { ensureLogin } from '../../blocks/profile/profile.js'; import escapeHtml from '../../utils/html.js'; @@ -867,8 +867,9 @@ async function loadSinglePageDiff(path) { async function init() { // Get params from URL const params = new URLSearchParams(window.location.search); - currentOrg = params.get('org'); - currentSite = params.get('site'); + const project = getProjectFromUrl(); + currentOrg = project.org; + currentSite = project.site; currentJob = params.get('job'); currentPath = params.get('path'); isEmbedMode = params.get('embed') === 'true'; @@ -879,13 +880,8 @@ async function init() { applyEmbedMode(); } - // Initialize config field only if not in embed mode - if (!isEmbedMode) { - await initConfigField(); - } - if (!currentOrg || !currentSite) { - showError('Missing org or site parameters. Use ?org=&site=&path= or &job='); + showError('Select an org/site in the header to continue.'); return; } diff --git a/tools/page-status/index.html b/tools/page-status/index.html index 6b84d02e..9146861b 100644 --- a/tools/page-status/index.html +++ b/tools/page-status/index.html @@ -8,6 +8,7 @@ > + Bulk Page Status Viewer @@ -28,19 +29,6 @@ Bulk Page Status - - - Organization - - - - - Site - - - - - Path diff --git a/tools/page-status/scripts.js b/tools/page-status/scripts.js index 6df71010..6734015a 100644 --- a/tools/page-status/scripts.js +++ b/tools/page-status/scripts.js @@ -1,10 +1,11 @@ import { registerToolReady } from '../../scripts/scripts.js'; import { decorateIcons } from '../../scripts/aem.js'; -import { initConfigField, updateConfig } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { ensureLogin } from '../../blocks/profile/profile.js'; import loadingMessages from './loading-messages.js'; const FORM = document.getElementById('status-form'); +const SUBMIT = FORM.querySelector('button[type="submit"]'); const TABLE = document.querySelector('table'); const CAPTION = TABLE.querySelector('caption'); const RESULTS = TABLE.querySelector('.results'); @@ -596,8 +597,7 @@ function downloadCSVFile(csvData) { async function runFromParams(search) { const params = new URLSearchParams(search); if (params && params.size > 0) { - const org = params.get('org'); - const site = params.get('site'); + const { org, site } = getProjectFromUrl(); const job = params.get('job'); if (org && site && job) { try { @@ -605,7 +605,6 @@ async function runFromParams(search) { setupJob(FORM, FORM.querySelector('button')); // fetch host config const { live, preview } = await validateHosts(org, site); - updateConfig(); // fetch page status and display results const jobUrl = `https://admin.hlx.page/job/${org}/${site}/main/status/${job}`; await runAndDisplayJob(jobUrl, live, preview); @@ -622,8 +621,31 @@ async function runFromParams(search) { } } +/** + * Displays a literal error message in the results table. + * @param {string} title - Error title text. + * @param {string} message - Error message text. + */ +function showTableMessage(title, message) { + const titleEl = ERROR.querySelector('strong'); + const messageEl = ERROR.querySelector('p:last-of-type'); + titleEl.textContent = title; + messageEl.textContent = message; + CAPTION.setAttribute('aria-hidden', true); + updateTableDisplay('error'); +} + +/** + * Enables the submit button only when an org/site is selected in the header. + */ +function syncSubmitEnabled() { + const { org, site } = getProjectFromUrl(); + if (SUBMIT) SUBMIT.disabled = !(org && site); +} + async function init() { - await initConfigField(); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); FORM.addEventListener('reset', () => { clearTable(RESULTS); @@ -633,8 +655,12 @@ async function init() { FORM.addEventListener('submit', async (e) => { e.preventDefault(); const { target, submitter } = e; + const { org, site } = getProjectFromUrl(); + if (!org || !site) { + showTableMessage('Error', 'Select an org/site in the header to continue.'); + return; + } const data = getFormData(target); - const { org, site } = data; if (!await ensureLogin(org, site)) { // not logged in yet, listen for profile-update event @@ -655,7 +681,6 @@ async function init() { const { path } = data; // fetch host config const { live, preview } = await validateHosts(org, site); - updateConfig(); // fetch page status and display results const jobUrl = await fetchJobUrl(org, site, path); if (!jobUrl) throw new Error('Failed to create page status job.'); @@ -715,8 +740,7 @@ async function init() { DIFFMODE.addEventListener('click', () => { if (DIFFMODE.disabled) return; - const data = getFormData(FORM); - const { org, site } = data; + const { org, site } = getProjectFromUrl(); if (!org || !site) return; // Get the job ID from the current URL params diff --git a/tools/page-status/styles.css b/tools/page-status/styles.css index 25b4d11e..ff350cc3 100644 --- a/tools/page-status/styles.css +++ b/tools/page-status/styles.css @@ -2,17 +2,13 @@ .page-status form#status-form { display: grid; grid-template: - 'config path' auto + 'path path' auto 'checkbox checkbox' auto 'button button' 1fr / 1fr 1fr; align-items: baseline; gap: 0 var(--spacing-l); } - .page-status form#status-form > .config-field { - grid-area: config; - } - .page-status form#status-form > .text-field { grid-area: path; } diff --git a/tools/pdp-scanner/index.html b/tools/pdp-scanner/index.html index c5ee097b..504328e0 100644 --- a/tools/pdp-scanner/index.html +++ b/tools/pdp-scanner/index.html @@ -12,7 +12,6 @@ PDP Scanner | AEM Tools - diff --git a/tools/robots-edit/index.html b/tools/robots-edit/index.html index 31c876f3..88629bc2 100644 --- a/tools/robots-edit/index.html +++ b/tools/robots-edit/index.html @@ -8,11 +8,11 @@ > + robots.txt Editor | AEM Tools - @@ -26,18 +26,6 @@ robots.txt Editor - - - Organization - - - - - Site - - - - Fetch diff --git a/tools/robots-edit/robots-edit.css b/tools/robots-edit/robots-edit.css index f29cc227..04ee61ae 100644 --- a/tools/robots-edit/robots-edit.css +++ b/tools/robots-edit/robots-edit.css @@ -12,10 +12,6 @@ grid-template-columns: 1fr max-content; gap: var(--spacing-l); } - - .robots-edit #admin-form .config-field { - grid-column: 1 / span 2; - } } .robots-edit #admin-form .button-wrapper { diff --git a/tools/robots-edit/robots-edit.js b/tools/robots-edit/robots-edit.js index 692f9c44..b6609ded 100644 --- a/tools/robots-edit/robots-edit.js +++ b/tools/robots-edit/robots-edit.js @@ -1,35 +1,42 @@ import { registerToolReady } from '../../scripts/scripts.js'; import admin from '../../scripts/helix-admin.js'; import { executeAdminRequest, AuthMode } from '../../utils/admin-request.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { logResponse } from '../../blocks/console/console.js'; const adminForm = document.getElementById('admin-form'); const bodyForm = document.getElementById('body-form'); const body = document.getElementById('body'); const consoleBlock = document.querySelector('.console'); -const site = document.getElementById('site'); -const org = document.getElementById('org'); +const fetchBtn = document.getElementById('fetch'); function logResult(result) { const { method, url } = result.request; logResponse(consoleBlock, result.status, [method, url, result.error]); } +function syncSubmitEnabled() { + const { org, site } = getProjectFromUrl(); + const ready = !!(org && site); + if (fetchBtn) fetchBtn.disabled = !ready; +} + async function init() { - await initConfigField(); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); bodyForm.addEventListener('submit', async (e) => { e.preventDefault(); - if (!org.value || !site.value) { + const { org, site } = getProjectFromUrl(); + if (!org || !site) { // eslint-disable-next-line no-alert - alert('Please select an organization and site first'); + alert('Select an org/site in the header to continue.'); return; } const result = await executeAdminRequest( - () => admin.config({ org: org.value, site: site.value }).select('robots.txt').update(body.value), - { org: org.value, site: site.value }, + () => admin.config({ org, site }).select('robots.txt').update(body.value), + { org, site }, ); if (!result) return; // 401 followed by cancelled login logResult(result); @@ -37,17 +44,18 @@ async function init() { adminForm.addEventListener('submit', async (e) => { e.preventDefault(); - if (!org.value || !site.value) { + const { org, site } = getProjectFromUrl(); + if (!org || !site) { // eslint-disable-next-line no-alert - alert('Please select an organization and site first'); + alert('Select an org/site in the header to continue.'); return; } // Preflight on Fetch (the natural entry point: fetch โ edit โ save). // Once the fetch succeeds, the user has an active session for any later save. const result = await executeAdminRequest( - () => admin.config({ org: org.value, site: site.value }).select('robots.txt').read(), - { org: org.value, site: site.value, policy: AuthMode.PREFLIGHT_AND_RETRY }, + () => admin.config({ org, site }).select('robots.txt').read(), + { org, site, policy: AuthMode.PREFLIGHT_AND_RETRY }, ); if (!result) return; // user cancelled login or timed out if (result.ok) body.value = await result.text(); diff --git a/tools/simple-config-editor/index.html b/tools/simple-config-editor/index.html index 5eb7490a..0756d6bf 100644 --- a/tools/simple-config-editor/index.html +++ b/tools/simple-config-editor/index.html @@ -9,6 +9,7 @@ > + Simple Config Editor @@ -32,21 +33,8 @@ Simple Config Editor - - - Organization - - - - - Site - - - - Load Config - Reset diff --git a/tools/simple-config-editor/simple-config-editor.js b/tools/simple-config-editor/simple-config-editor.js index 3e96202f..dc919c88 100644 --- a/tools/simple-config-editor/simple-config-editor.js +++ b/tools/simple-config-editor/simple-config-editor.js @@ -1,6 +1,6 @@ import { registerToolReady } from '../../scripts/scripts.js'; import { logResponse, logMessage } from '../../blocks/console/console.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { createModal } from '../../blocks/modal/modal.js'; import admin from '../../scripts/helix-admin.js'; import { executeAdminRequest, AuthMode } from '../../utils/admin-request.js'; @@ -87,8 +87,6 @@ const CDN_FIELDS = { ], }; -const org = document.getElementById('org'); -const site = document.getElementById('site'); const configEditor = document.getElementById('config-editor'); const configTbody = document.getElementById('config-tbody'); const consoleBlock = document.querySelector('.console'); @@ -919,10 +917,9 @@ async function performMigration(configToMigrate) { * 401 retries both. PREFLIGHT_AND_RETRY because this is the entry point. */ async function loadConfig() { - const orgVal = org.value; - const siteVal = site.value; + const { org: orgVal, site: siteVal } = getProjectFromUrl(); if (!orgVal || !siteVal) { - logMessage(consoleBlock, 'error', ['LOAD', 'Please select both organization and site', '']); + logMessage(consoleBlock, 'error', ['LOAD', 'Select an org/site in the header to continue.', '']); return; } @@ -1075,12 +1072,19 @@ function toggleInheritedProperties() { populateConfigTable(); } +function syncSubmitEnabled() { + const { org, site } = getProjectFromUrl(); + const ready = !!(org && site); + const loadBtn = document.getElementById('load-config'); + if (loadBtn) loadBtn.disabled = !ready; +} + /** * Initializes the config editor */ async function init() { - // Initialize config field (handles URL params, localStorage, sidekick auto-population) - await initConfigField(); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); // Load config when form is submitted. Auth is handled inside loadConfig // via executeAdminRequest with PREFLIGHT_AND_RETRY. @@ -1103,7 +1107,8 @@ const initPromise = init(); initPromise.then(async () => { // Auto-load config if both org and site are set from URL params - if (org.value && site.value) { + const { org, site } = getProjectFromUrl(); + if (org && site) { logMessage(consoleBlock, 'info', ['AUTO-LOAD', 'Auto-loading configuration from URL parameters', '']); await loadConfig(); } diff --git a/tools/site-admin/index.html b/tools/site-admin/index.html index 5999d09b..1d1a6bef 100644 --- a/tools/site-admin/index.html +++ b/tools/site-admin/index.html @@ -9,6 +9,7 @@ > + Site Admin @@ -24,36 +25,15 @@ Site Admin - Manage the sites of an org, or a single site you have access to. + Manage the sites of an org. - - - Organization - - - - - Make sure you are logged into sidekick on a project of the Organization selected - - - - - Site (optional) - - - - - - - List - Reset diff --git a/tools/site-admin/site-admin.js b/tools/site-admin/site-admin.js index a6f16c4d..6bbb108e 100644 --- a/tools/site-admin/site-admin.js +++ b/tools/site-admin/site-admin.js @@ -1,9 +1,9 @@ import { registerToolReady } from '../../scripts/scripts.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { logResponse } from '../../blocks/console/console.js'; import { ensureLogin } from '../../blocks/profile/profile.js'; import { VIEW_STORAGE_KEY } from './helpers/constants.js'; -import { fetchSites, getSitePath, adminFetch } from './helpers/api-helper.js'; +import { fetchSites, fetchSiteDetails } from './helpers/api-helper.js'; import { loadIcon, icon, @@ -14,107 +14,93 @@ import { import { openAddSiteModal } from './helpers/modals.js'; import createSiteCard from './helpers/site-card.js'; -const org = document.getElementById('org'); -const site = document.getElementById('site'); +const adminForm = document.getElementById('site-admin-form'); +const submitButton = adminForm.querySelector('button[type="submit"]'); const consoleBlock = document.querySelector('.console'); const sitesElem = document.querySelector('div#sites'); -const siteNotFoundHint = document.querySelector('.site-not-found-hint'); // Logging wrapper for API calls const logFn = (status, details) => logResponse(consoleBlock, status, details); -const hideSiteNotFoundHint = () => { - siteNotFoundHint.hidden = true; - siteNotFoundHint.textContent = ''; -}; - -const showSiteNotFoundHint = (siteName, orgValue) => { - siteNotFoundHint.textContent = `Site "${siteName}" not found in Organization "${orgValue}"`; - siteNotFoundHint.hidden = false; -}; - -const applyDetailsToCard = (card, details) => { - const contentUrl = details.content?.source?.url || ''; - const contentSourceType = details.content?.source?.type || ''; - const codeUrl = details.code?.source?.url || ''; - const sourceType = getContentSourceType(contentUrl, contentSourceType); - - const badge = card.querySelector('.source-badge'); - if (badge) { - badge.textContent = sourceType.label; - badge.className = `source-badge source-${sourceType.type}`; - badge.title = sourceType.type.toUpperCase(); - } +const populateCardDetails = (card, orgValue) => { + const siteName = card.dataset.site; + fetchSiteDetails(orgValue, siteName).then((details) => { + if (!details) return; + + const contentUrl = details.content?.source?.url || ''; + const contentSourceType = details.content?.source?.type || ''; + const codeUrl = details.code?.source?.url || ''; + const sourceType = getContentSourceType(contentUrl, contentSourceType); + + const badge = card.querySelector('.source-badge'); + if (badge) { + badge.textContent = sourceType.label; + badge.className = `source-badge source-${sourceType.type}`; + badge.title = sourceType.type.toUpperCase(); + } - const codeSource = card.querySelector('.site-card-source[data-type="code"]'); - const contentSource = card.querySelector('.site-card-source[data-type="content"]'); - const contentEditorUrl = getDAEditorURL(contentUrl); + const codeSource = card.querySelector('.site-card-source[data-type="code"]'); + const contentSource = card.querySelector('.site-card-source[data-type="content"]'); + const contentEditorUrl = getDAEditorURL(contentUrl); - if (codeSource) { - codeSource.title = codeUrl || 'Not configured'; - if (codeUrl) codeSource.href = codeUrl; - else codeSource.removeAttribute('href'); - } - if (contentSource) { - contentSource.title = contentUrl || 'Not configured'; - if (contentEditorUrl) contentSource.href = contentEditorUrl; - else contentSource.removeAttribute('href'); - } + if (codeSource) { + codeSource.title = codeUrl || 'Not configured'; + if (codeUrl) codeSource.href = codeUrl; + else codeSource.removeAttribute('href'); + } + if (contentSource) { + contentSource.title = contentUrl || 'Not configured'; + if (contentEditorUrl) contentSource.href = contentEditorUrl; + else contentSource.removeAttribute('href'); + } - card.dataset.codeUrl = codeUrl; - card.dataset.contentUrl = contentUrl; + card.dataset.codeUrl = codeUrl; + card.dataset.contentUrl = contentUrl; - const isByogit = details.code?.source?.type === 'byogit' - || codeUrl.includes('cm-repo.adobe.io'); - if (isByogit) { - card.dataset.byogitOwner = details.code?.source?.owner || details.code?.owner || ''; - card.dataset.byogitRepo = details.code?.source?.repo || details.code?.repo || ''; - } + const isByogit = details.code?.source?.type === 'byogit' + || codeUrl.includes('cm-repo.adobe.io'); + if (isByogit) { + card.dataset.byogitOwner = details.code?.source?.owner || details.code?.owner || ''; + card.dataset.byogitRepo = details.code?.source?.repo || details.code?.repo || ''; + } - const hasPreviewAuth = details.access?.site || details.access?.preview; - const hasLiveAuth = details.access?.site || details.access?.live; - const hasAnyAuth = hasPreviewAuth || hasLiveAuth; + const hasPreviewAuth = details.access?.site || details.access?.preview; + const hasLiveAuth = details.access?.site || details.access?.live; + const hasAnyAuth = hasPreviewAuth || hasLiveAuth; - const lighthouseBtn = card.querySelector('.menu-item[data-action="lighthouse"]'); - if (hasAnyAuth) { - card.dataset.hasAuth = 'true'; - if (lighthouseBtn) { - lighthouseBtn.disabled = true; - lighthouseBtn.title = 'Lighthouse unavailable for authenticated sites'; - } - if (hasPreviewAuth) { - card.querySelector('.auth-icon.auth-preview')?.removeAttribute('aria-hidden'); - } - if (hasLiveAuth) { - card.querySelector('.auth-icon.auth-live')?.removeAttribute('aria-hidden'); - } - } else { - delete card.dataset.hasAuth; - if (lighthouseBtn) { - lighthouseBtn.disabled = false; - lighthouseBtn.title = ''; + const lighthouseBtn = card.querySelector('.menu-item[data-action="lighthouse"]'); + if (hasAnyAuth) { + card.dataset.hasAuth = 'true'; + if (lighthouseBtn) { + lighthouseBtn.disabled = true; + lighthouseBtn.title = 'Lighthouse unavailable for authenticated sites'; + } + if (hasPreviewAuth) { + card.querySelector('.auth-icon.auth-preview').removeAttribute('aria-hidden'); + } + if (hasLiveAuth) { + card.querySelector('.auth-icon.auth-live').removeAttribute('aria-hidden'); + } + } else { + delete card.dataset.hasAuth; + if (lighthouseBtn) { + lighthouseBtn.disabled = false; + lighthouseBtn.title = ''; + } + card.querySelector('.auth-icon.auth-preview')?.setAttribute('aria-hidden', 'true'); + card.querySelector('.auth-icon.auth-live')?.setAttribute('aria-hidden', 'true'); } - card.querySelector('.auth-icon.auth-preview')?.setAttribute('aria-hidden', 'true'); - card.querySelector('.auth-icon.auth-live')?.setAttribute('aria-hidden', 'true'); - } - - const cdnHost = details.cdn?.prod?.host || details.cdn?.host; - const cdnEl = card.querySelector('.site-card-cdn'); - if (cdnHost && cdnEl) { - cdnEl.querySelector('span').textContent = cdnHost; - cdnEl.href = `https://${cdnHost}`; - cdnEl.classList.add('visible'); - } else if (cdnEl) { - cdnEl.classList.remove('visible'); - } -}; -const populateCardDetails = async (card, orgValue) => { - const siteName = card.dataset.site; - const resp = await adminFetch(getSitePath(orgValue, siteName), {}, logFn); - if (!resp.ok) return; - const details = await resp.json(); - applyDetailsToCard(card, details); + const cdnHost = details.cdn?.prod?.host || details.cdn?.host; + const cdnEl = card.querySelector('.site-card-cdn'); + if (cdnHost && cdnEl) { + cdnEl.querySelector('span').textContent = cdnHost; + cdnEl.href = `https://${cdnHost}`; + cdnEl.classList.add('visible'); + } else if (cdnEl) { + cdnEl.classList.remove('visible'); + } + }); }; const findCardBySite = (name) => sitesElem.querySelector(`.site-card[data-site="${CSS.escape(name)}"]`); @@ -127,11 +113,10 @@ const updateSiteCount = () => { let detailsObserver; -const displaySites = (sites, { limitedAccess = false } = {}) => { - sitesElem.removeAttribute('aria-hidden'); +const displaySites = (sites, orgValue) => { + sitesElem.ariaHidden = false; sitesElem.textContent = ''; - const selectedSite = new URLSearchParams(window.location.search).get('site'); const savedView = localStorage.getItem(VIEW_STORAGE_KEY) || 'grid'; const header = document.createElement('div'); @@ -139,59 +124,49 @@ const displaySites = (sites, { limitedAccess = false } = {}) => { header.innerHTML = ` ${sites.length} site${sites.length !== 1 ? 's' : ''} - ${sites.length > 1 ? ` - - - - - - ${icon('grid')} - - - ${icon('list')} - - - ` : ''} - ${limitedAccess ? '' : '+ Add Site'} + + + + + + ${icon('grid')} + + + ${icon('list')} + + + + Add Site `; - if (!limitedAccess) { - header.querySelector('.add-site-btn').addEventListener('click', () => openAddSiteModal(org.value, '', '', logFn)); - } + header.querySelector('.add-site-btn').addEventListener('click', () => openAddSiteModal(orgValue, '', '', logFn)); sitesElem.appendChild(header); const grid = document.createElement('div'); - grid.className = `sites-grid ${sites.length > 1 && savedView === 'list' ? 'list-view' : ''}`; - - if (sites.length > 1) { - const searchInput = header.querySelector('.search-input'); - searchInput.addEventListener('input', (e) => { - const query = e.target.value.toLowerCase().trim(); - grid.querySelectorAll('.site-card').forEach((card) => { - const siteName = card.dataset.site.toLowerCase(); - card.setAttribute('aria-hidden', !siteName.includes(query)); - }); + grid.className = `sites-grid ${savedView === 'list' ? 'list-view' : ''}`; + + const searchInput = header.querySelector('.search-input'); + searchInput.addEventListener('input', (e) => { + const query = e.target.value.toLowerCase().trim(); + grid.querySelectorAll('.site-card').forEach((card) => { + const siteName = card.dataset.site.toLowerCase(); + card.setAttribute('aria-hidden', !siteName.includes(query)); }); + }); - header.querySelectorAll('.view-btn').forEach((btn) => { - btn.addEventListener('click', () => { - const { view } = btn.dataset; - header.querySelectorAll('.view-btn').forEach((b) => b.classList.remove('active')); - btn.classList.add('active'); - grid.classList.toggle('list-view', view === 'list'); - localStorage.setItem(VIEW_STORAGE_KEY, view); - }); + header.querySelectorAll('.view-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const { view } = btn.dataset; + header.querySelectorAll('.view-btn').forEach((b) => b.classList.remove('active')); + btn.classList.add('active'); + grid.classList.toggle('list-view', view === 'list'); + localStorage.setItem(VIEW_STORAGE_KEY, view); }); - } + }); - const favorites = getFavorites(org.value); + const favorites = getFavorites(orgValue); const sortedSites = [...sites].sort((a, b) => { - if (selectedSite) { - if (a.name === selectedSite) return -1; - if (b.name === selectedSite) return 1; - } const aFav = favorites.includes(a.name); const bFav = favorites.includes(b.name); if (aFav && !bFav) return -1; @@ -203,69 +178,34 @@ const displaySites = (sites, { limitedAccess = false } = {}) => { detailsObserver = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { - populateCardDetails(entry.target, org.value); + populateCardDetails(entry.target, orgValue); detailsObserver.unobserve(entry.target); } }); }, { rootMargin: '200px' }); - sortedSites.forEach((s) => { - const card = createSiteCard(s, org.value, { limitedAccess }); + sortedSites.forEach((site) => { + const card = createSiteCard(site, orgValue); grid.appendChild(card); - if (s.details) { - applyDetailsToCard(card, s.details); - } else { - detailsObserver.observe(card); - } + detailsObserver.observe(card); }); sitesElem.appendChild(grid); - - if (selectedSite) { - const selectedCard = findCardBySite(selectedSite); - if (selectedCard) { - selectedCard.classList.add('selected'); - } else { - showSiteNotFoundHint(selectedSite, org.value); - } - } -}; - -const showAccessMessage = (text) => { - sitesElem.removeAttribute('aria-hidden'); - const msg = document.createElement('p'); - msg.className = 'access-message'; - msg.textContent = text; - sitesElem.appendChild(msg); }; const displaySitesForOrg = async (orgValue) => { sitesElem.setAttribute('aria-hidden', 'true'); sitesElem.replaceChildren(); - hideSiteNotFoundHint(); - const selectedSite = new URLSearchParams(window.location.search).get('site'); const { sites, status } = await fetchSites(orgValue, logFn); if (status === 200 && sites) { - displaySites(sites); + displaySites(sites, orgValue); } else if (status === 401) { const loggedIn = await ensureLogin(orgValue); if (loggedIn) { return displaySitesForOrg(orgValue); } - } else if (status === 403 && selectedSite) { - const siteResp = await adminFetch(getSitePath(orgValue, selectedSite), {}, logFn); - if (siteResp.ok) { - const details = await siteResp.json(); - displaySites([{ name: selectedSite, details }], { limitedAccess: true }); - } else if (siteResp.status === 403 || siteResp.status === 404) { - showAccessMessage(`Site "${selectedSite}" isn't available in org "${orgValue}". It may not exist, or you may not have access to it.`); - } else { - showAccessMessage(`Failed to load site "${selectedSite}" (HTTP ${siteResp.status}).`); - } - } else if (status === 403) { - showAccessMessage("You're not an admin of this org. Enter a site name above to manage just that site."); } return null; }; @@ -309,6 +249,11 @@ window.addEventListener('sites-refresh', (e) => { displaySitesForOrg(orgValue); }); +function syncSubmitEnabled() { + const { org } = getProjectFromUrl(); + submitButton.disabled = !org; +} + const initSiteAdmin = async () => { const neededIcons = [ 'code', 'document', 'edit', 'copy', 'external', 'trash', 'key', @@ -316,42 +261,30 @@ const initSiteAdmin = async () => { 'user', 'search', 'grid', 'list', 'star', ]; await Promise.all(neededIcons.map(loadIcon)); - await initConfigField(); - if (!org.value) org.value = localStorage.getItem('org') || 'adobe'; - const form = document.getElementById('site-admin-form'); - form.addEventListener('submit', async (e) => { + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); + + adminForm.addEventListener('submit', async (e) => { e.preventDefault(); - const { submitter } = e; - - const orgValue = org.value; - if (!orgValue) return; - - const url = new URL(window.location.href); - url.searchParams.set('org', orgValue); - const siteValue = site.value.trim() || ''; - if (siteValue) url.searchParams.set('site', siteValue); - else url.searchParams.delete('site'); - window.history.replaceState({}, document.title, url.href); - - const loggedIn = await ensureLogin(orgValue, siteValue || undefined); - if (!loggedIn) { - window.addEventListener('profile-update', ({ detail: loginInfo }) => { - if (loginInfo.includes(orgValue)) submitter?.click(); - }, { once: true }); + const { org } = getProjectFromUrl(); + if (!org) { + // eslint-disable-next-line no-alert + alert('Select an org in the header to continue.'); return; } - - displaySitesForOrg(orgValue); + const loggedIn = await ensureLogin(org); + if (loggedIn) { + displaySitesForOrg(org); + } }); - form.addEventListener('reset', hideSiteNotFoundHint); - - const params = new URLSearchParams(window.location.search); - if (params.get('org')) { - org.value = params.get('org'); - if (params.get('site')) site.value = params.get('site'); - document.getElementById('list').click(); + const { org } = getProjectFromUrl(); + if (org) { + const loggedIn = await ensureLogin(org); + if (loggedIn) { + displaySitesForOrg(org); + } } }; diff --git a/tools/site-query/index.html b/tools/site-query/index.html index ccd29eca..4f3d0739 100644 --- a/tools/site-query/index.html +++ b/tools/site-query/index.html @@ -4,6 +4,7 @@ + Site Query @@ -23,19 +24,6 @@ Site Query - - - Organization - - - - - Site - - - - - Query diff --git a/tools/site-query/scripts.js b/tools/site-query/scripts.js index eda563b1..510c95b0 100644 --- a/tools/site-query/scripts.js +++ b/tools/site-query/scripts.js @@ -1,5 +1,5 @@ import { registerToolReady } from '../../scripts/scripts.js'; -import { initConfigField, updateConfig } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { ensureLogin } from '../../blocks/profile/profile.js'; import admin from '../../scripts/helix-admin.js'; import { executeAdminRequest, AuthMode } from '../../utils/admin-request.js'; @@ -72,6 +72,11 @@ function clearResults(table) { function updateTableError(table, errCode, org, site) { const { title, msg } = (() => { switch (errCode) { + case 'project': + return { + title: 'No Project Selected', + msg: 'Select an org/site in the header to continue.', + }; case 401: ensureLogin(org, site); return { @@ -284,7 +289,6 @@ async function processUrl(sitemapUrl, query, queryType, org, site) { async function init(doc) { doc.querySelector('.site-query').dataset.status = 'loading'; - await initConfigField(); const form = doc.querySelector('#search-form'); const table = doc.querySelector('.table table'); @@ -293,8 +297,17 @@ async function init(doc) { const noResults = table.querySelector('tbody.no-results'); const stopButton = doc.querySelector('#stop-search'); const caption = table.querySelector('caption'); + const submitButton = form.querySelector('button[type="submit"]'); let stopped = false; + const syncSubmitEnabled = () => { + const { org, site } = getProjectFromUrl(); + const ready = !!(org && site); + if (submitButton) submitButton.disabled = !ready; + }; + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); + stopButton.addEventListener('click', () => { stopped = true; stopButton.setAttribute('aria-hidden', 'true'); @@ -304,13 +317,21 @@ async function init(doc) { form.addEventListener('submit', async (e) => { e.preventDefault(); + const { org, site } = getProjectFromUrl(); + if (!org || !site) { + updateTableError(table, 'project', org, site); + stopButton.setAttribute('aria-hidden', 'true'); + caption.setAttribute('aria-hidden', 'true'); + return; + } + stopped = false; results.setAttribute('aria-hidden', 'false'); error.setAttribute('aria-hidden', 'true'); noResults.setAttribute('aria-hidden', 'true'); const { - org, site, query, sitemap, queryType, path, + query, sitemap, queryType, path, } = getFormData(form); try { @@ -332,8 +353,6 @@ async function init(doc) { return; } - updateConfig(); - const sitemapUrls = sitemap.endsWith('.json') ? fetchQueryIndex(sitemap, live) : fetchSitemap(sitemap, live); let searched = 0; diff --git a/tools/sitemap-admin/index.html b/tools/sitemap-admin/index.html index 299efbef..f7abe420 100644 --- a/tools/sitemap-admin/index.html +++ b/tools/sitemap-admin/index.html @@ -6,12 +6,12 @@ content="script-src 'nonce-aem' 'strict-dynamic'; base-uri 'self'; object-src 'none';" move-to-http-header="true"> + Sitemap Admin | AEM Tools - @@ -30,18 +30,6 @@ Sitemap Admin - - - Organization - - - - - Site - - - - Fetch diff --git a/tools/sitemap-admin/sitemap-admin.js b/tools/sitemap-admin/sitemap-admin.js index b27e5388..e526750d 100644 --- a/tools/sitemap-admin/sitemap-admin.js +++ b/tools/sitemap-admin/sitemap-admin.js @@ -1,13 +1,12 @@ import { registerToolReady } from '../../scripts/scripts.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import admin from '../../scripts/helix-admin.js'; import { executeAdminRequest, AuthMode } from '../../utils/admin-request.js'; import { logResponse } from '../../blocks/console/console.js'; import { escapeXml, parseHreflang, collectSitemapEntries } from './utils.js'; const adminForm = document.getElementById('admin-form'); -const site = document.getElementById('site'); -const org = document.getElementById('org'); +const submitButton = adminForm.querySelector('button[type="submit"]'); const consoleBlock = document.querySelector('.console'); const addSitemapButton = document.getElementById('add-sitemap'); @@ -45,10 +44,11 @@ function logResult(result) { } async function saveSitemapYaml() { + const { org, site } = getProjectFromUrl(); const yamlText = YAML.stringify(loadedSitemaps); return executeAdminRequest( - () => admin.config({ org: org.value, site: site.value }).select('content/sitemap.yaml').update(yamlText), - { org: org.value, site: site.value }, + () => admin.config({ org, site }).select('content/sitemap.yaml').update(yamlText), + { org, site }, ); } @@ -354,9 +354,10 @@ async function removeLanguage(sitemapName, langCode) { } async function generateSitemap(destination) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( - () => admin.sitemap({ org: org.value, site: site.value }).update(destination), - { org: org.value, site: site.value }, + () => admin.sitemap({ org, site }).update(destination), + { org, site }, ); if (!result) return; const { method, url } = result.request; @@ -491,9 +492,10 @@ function populateSitemaps(sitemaps) { } async function fetchCdnProdHost() { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( - () => admin.config({ org: org.value, site: site.value }).select('cdn.json').read(), - { org: org.value, site: site.value }, + () => admin.config({ org, site }).select('cdn.json').read(), + { org, site }, ); if (!result) return; logResult(result); @@ -571,8 +573,15 @@ async function showIndexDialog() { }); } +function syncSubmitEnabled() { + const { org, site } = getProjectFromUrl(); + const ready = !!(org && site); + if (submitButton) submitButton.disabled = !ready; +} + async function init() { - await initConfigField(); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); addSitemapButton.addEventListener('click', () => { showTypeSelectionDialog(); @@ -580,16 +589,17 @@ async function init() { adminForm.addEventListener('submit', async (e) => { e.preventDefault(); - if (!org.value || !site.value) { + const { org, site } = getProjectFromUrl(); + if (!org || !site) { // eslint-disable-next-line no-alert - alert('Please select an organization and site first'); + alert('Select an org/site in the header to continue.'); return; } // Preflight on the fetch (entry point); the resulting session covers later saves. const result = await executeAdminRequest( - () => admin.config({ org: org.value, site: site.value }).select('content/sitemap.yaml').read(), - { org: org.value, site: site.value, policy: AuthMode.PREFLIGHT_AND_RETRY }, + () => admin.config({ org, site }).select('content/sitemap.yaml').read(), + { org, site, policy: AuthMode.PREFLIGHT_AND_RETRY }, ); if (!result) return; logResult(result); @@ -609,7 +619,8 @@ async function init() { } }); - if (org.value && site.value) { + const { org, site } = getProjectFromUrl(); + if (org && site) { adminForm.requestSubmit(); } } diff --git a/tools/snapshot-admin/index.html b/tools/snapshot-admin/index.html index 3ec433a4..1b3a55e1 100644 --- a/tools/snapshot-admin/index.html +++ b/tools/snapshot-admin/index.html @@ -8,6 +8,7 @@ > + Snapshot Admin @@ -27,21 +28,8 @@ Snapshot Admin - - - Organization - - - - - Site - - - - Load Snapshots - Reset diff --git a/tools/snapshot-admin/snapshot-admin.css b/tools/snapshot-admin/snapshot-admin.css index 5443e827..6814fa55 100644 --- a/tools/snapshot-admin/snapshot-admin.css +++ b/tools/snapshot-admin/snapshot-admin.css @@ -49,33 +49,6 @@ gap: var(--spacing-200); } -/* Config field styling for org/site fields */ -.form-field.config-field { - display: grid; -} - -@media (width >= 600px) { - .form-field.config-field { - grid-template-columns: 1fr 1fr; - gap: var(--spacing-l); - } - - .form-field.config-field .form-field { - margin-top: 0; - } - - .form-field.config-field .site-field { - position: relative; - } - - .form-field.config-field .site-field::before { - content: '/'; - position: absolute; - left: calc((var(--spacing-l) + 0.5ch) / -2); - bottom: calc(var(--border-m) + 0.4em); - } -} - /* Snapshot list */ .snapshots-container { margin-top: var(--spacing-500); diff --git a/tools/snapshot-admin/snapshot-admin.js b/tools/snapshot-admin/snapshot-admin.js index ba983374..54009107 100644 --- a/tools/snapshot-admin/snapshot-admin.js +++ b/tools/snapshot-admin/snapshot-admin.js @@ -1,4 +1,5 @@ import { registerToolReady } from '../../scripts/scripts.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { fetchSnapshots, saveManifest, @@ -7,8 +8,7 @@ import { // DOM Elements const sitePathForm = document.getElementById('site-path-form'); -const orgInput = document.getElementById('org'); -const siteInput = document.getElementById('site'); +const submitButton = sitePathForm.querySelector('button[type="submit"]'); const snapshotsContainer = document.getElementById('snapshots-container'); const snapshotsList = document.getElementById('snapshots-list'); const createSnapshotForm = document.getElementById('create-snapshot-form'); @@ -23,8 +23,6 @@ const modalClose = document.querySelector('.modal-close'); const modalOk = document.querySelector('.modal-ok'); const modalOverlay = document.querySelector('.modal-overlay'); -let currentOrg = ''; -let currentSite = ''; let snapshots = []; /** @@ -117,12 +115,13 @@ function logResponse(cols) { */ function createSnapshotCard(snapshot) { const { name } = snapshot; + const { org, site } = getProjectFromUrl(); return ` ${name} - Edit + Edit Delete @@ -249,36 +248,12 @@ async function createSnapshot(snapshotName) { } } -/** - * Handle org input changes to enable/disable site field - */ -orgInput.addEventListener('input', () => { - siteInput.disabled = !orgInput.value.trim(); -}); - -/** - * Handle org and site input changes to update currentOrg and currentSite - */ -siteInput.addEventListener('change', async () => { - if (orgInput.value.trim() && siteInput.value.trim()) { - currentOrg = orgInput.value.trim(); - currentSite = siteInput.value.trim(); - setOrgSite(currentOrg, currentSite); - } -}); - -/** - * Handle when config fields are populated programmatically (e.g., from sidekick) - */ -siteInput.addEventListener('input', async () => { - if (orgInput.value && siteInput.value && !currentOrg && !currentSite) { - currentOrg = orgInput.value.trim(); - currentSite = siteInput.value.trim(); - siteInput.disabled = false; - setOrgSite(currentOrg, currentSite); - await loadSnapshots(); - } -}); +function syncSubmitEnabled() { + const { org, site } = getProjectFromUrl(); + const ready = !!(org && site); + if (submitButton) submitButton.disabled = !ready; + setOrgSite(org, site); +} /** * Handle site path form submission @@ -286,29 +261,15 @@ siteInput.addEventListener('input', async () => { sitePathForm.addEventListener('submit', async (e) => { e.preventDefault(); - try { - const org = orgInput.value.trim(); - const site = siteInput.value.trim(); - - if (!org || !site) { - await showModal('Missing Information', 'Please enter both organization and site'); - return; - } - - currentOrg = org; - currentSite = site; - - const sitePath = `${org}/${site}`; - localStorage.setItem('snapshot-admin-site-path', sitePath); + const { org, site } = getProjectFromUrl(); + if (!org || !site) { + await showModal('Missing Information', 'Select an org/site in the header to continue.'); + return; + } + try { + setOrgSite(org, site); await loadSnapshots(); - - // Update URL - const url = new URL(window.location); - url.searchParams.set('org', org); - url.searchParams.set('site', site); - // eslint-disable-next-line no-restricted-globals - window.history.pushState({}, '', url); } catch (error) { await showModal('Error', `Error loading snapshots: ${error.message}`); } @@ -330,44 +291,22 @@ createSnapshotForm.addEventListener('submit', async (e) => { }); async function init() { - // Initialize from URL parameters or localStorage + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); + const params = new URLSearchParams(window.location.search); const snapshotParam = params.get('snapshot'); - const orgParam = params.get('org'); - const siteParam = params.get('site'); // Check if we have a snapshot URL parameter and send to snapshot-details.html if (snapshotParam) { window.location.href = `snapshot-details.html?snapshot=${snapshotParam}`; - } else if (orgParam && siteParam) { - // Use org and site parameters - orgInput.value = orgParam; - siteInput.value = siteParam; - currentOrg = orgParam; - currentSite = siteParam; - // Enable the site field since we have both org and site values - siteInput.disabled = false; - setOrgSite(currentOrg, currentSite); + return; + } + + const { org, site } = getProjectFromUrl(); + if (org && site) { + setOrgSite(org, site); await loadSnapshots(); - } else { - // No snapshot parameter, initialize config fields normally - try { - const { initConfigField } = await import('../../utils/config/config.js'); - await initConfigField(); - siteInput.disabled = false; - - // Check if config fields have values and set them up - if (orgInput.value && siteInput.value) { - currentOrg = orgInput.value; - currentSite = siteInput.value; - setOrgSite(currentOrg, currentSite); - await loadSnapshots(); - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to initialize config fields:', error); - // Continue loading the page even if config initialization fails - } } } diff --git a/tools/user-admin/index.html b/tools/user-admin/index.html index 01ee4962..7361c586 100644 --- a/tools/user-admin/index.html +++ b/tools/user-admin/index.html @@ -9,10 +9,10 @@ > + User Admin - @@ -31,24 +31,8 @@ User Admin - - - Organization - - - - - Site (optional) - - - - Leave blank to manage org-level users, or select a site to manage site-specific access. - - - Fetch Users - Reset diff --git a/tools/user-admin/user-admin.css b/tools/user-admin/user-admin.css index 4808f0fa..65d46e09 100644 --- a/tools/user-admin/user-admin.css +++ b/tools/user-admin/user-admin.css @@ -10,10 +10,6 @@ grid-template-columns: 1fr max-content; gap: var(--spacing-l); } - - .user-admin #admin-form .config-field { - grid-column: 1 / span 2; - } } .user-admin #admin-form .button-wrapper { @@ -524,10 +520,3 @@ dialog.user-admin-modal .add-another-btn { dialog.user-admin-modal.confirm-dialog { max-width: 400px; } - -@media (width >= 600px) { - .user-admin .form-field.config-field .site-field::before { - bottom: 50%; - transform: translateY(50%); - } -} diff --git a/tools/user-admin/user-admin.js b/tools/user-admin/user-admin.js index cb483a5d..1a935989 100644 --- a/tools/user-admin/user-admin.js +++ b/tools/user-admin/user-admin.js @@ -1,5 +1,5 @@ import { registerToolReady } from '../../scripts/scripts.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import { logResponse } from '../../blocks/console/console.js'; import { loadIcon, icon, showToast } from '../../utils/card-ui/card-ui.js'; import admin from '../../scripts/helix-admin.js'; @@ -9,8 +9,7 @@ import { parseUsersFromAccessConfig, buildAccessConfig } from './utils.js'; const VIEW_STORAGE_KEY = 'user-admin-view'; const adminForm = document.getElementById('admin-form'); -const site = document.getElementById('site'); -const org = document.getElementById('org'); +const submitButton = adminForm.querySelector('button[type="submit"]'); const consoleBlock = document.querySelector('.console'); const usersContainer = document.getElementById('users-container'); const accessConfig = { type: 'org', users: [], originalSiteAccess: {} }; @@ -62,28 +61,30 @@ const ROLE_DESCRIPTIONS = { }; async function getOrgConfig() { + const { org } = getProjectFromUrl(); const result = await executeAdminRequest( async () => { - const res = await admin.config({ org: org.value }).read(); + const res = await admin.config({ org }).read(); logResponse(consoleBlock, res.status, [res.request.method, res.request.url, res.error]); return res; }, - { org: org.value, policy: AuthMode.PREFLIGHT_AND_RETRY }, + { org, policy: AuthMode.PREFLIGHT_AND_RETRY }, ); if (!result) return null; return result.ok ? result.json() : null; } async function getSiteAccessConfig() { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( async () => { - const res = await admin.config({ org: org.value, site: site.value }) + const res = await admin.config({ org, site }) .select('access.json') .read(); logResponse(consoleBlock, res.status, [res.request.method, res.request.url, res.error]); return res; }, - { org: org.value, site: site.value, policy: AuthMode.PREFLIGHT_AND_RETRY }, + { org, site, policy: AuthMode.PREFLIGHT_AND_RETRY }, ); if (!result) return null; if (result.status === 404) return { admin: { role: {} } }; @@ -91,30 +92,32 @@ async function getSiteAccessConfig() { } async function updateSiteAccess() { + const { org, site } = getProjectFromUrl(); const access = buildAccessConfig(accessConfig.originalSiteAccess, accessConfig.users); const result = await executeAdminRequest( async () => { - const res = await admin.config({ org: org.value, site: site.value }) + const res = await admin.config({ org, site }) .select('access.json') .update(JSON.stringify(access)); logResponse(consoleBlock, res.status, [res.request.method, res.request.url, res.error]); return res; }, - { org: org.value, site: site.value }, + { org, site }, ); return result?.ok ?? false; } async function updateOrgUserRoles(user) { + const { org } = getProjectFromUrl(); const result = await executeAdminRequest( async () => { - const res = await admin.config({ org: org.value }) + const res = await admin.config({ org }) .select(`users/${user.id}.json`) .update(JSON.stringify(user)); logResponse(consoleBlock, res.status, [res.request.method, res.request.url, res.error]); return res; }, - { org: org.value }, + { org }, ); return result?.ok ?? false; } @@ -125,15 +128,16 @@ async function deleteUserFromSite(user) { } async function deleteUserFromOrg(user) { + const { org } = getProjectFromUrl(); const result = await executeAdminRequest( async () => { - const res = await admin.config({ org: org.value }) + const res = await admin.config({ org }) .select(`users/${user.id}.json`) .remove(); logResponse(consoleBlock, res.status, [res.request.method, res.request.url, res.error]); return res; }, - { org: org.value }, + { org }, ); return result?.ok ?? false; } @@ -152,19 +156,20 @@ async function addUsersToSite(users) { } async function addUsersToOrg(users) { + const { org } = getProjectFromUrl(); let added = 0; try { await users.reduce(async (prevPromise, user) => { await prevPromise; const result = await executeAdminRequest( async () => { - const res = await admin.config({ org: org.value }) + const res = await admin.config({ org }) .select('users.json') .update(JSON.stringify(user)); logResponse(consoleBlock, res.status, [res.request.method, res.request.url, res.error]); return res; }, - { org: org.value }, + { org }, ); if (result?.ok) { added += 1; @@ -829,11 +834,23 @@ function displayUsers(users) { usersContainer.appendChild(grid); } +function syncSubmitEnabled() { + const { org } = getProjectFromUrl(); + if (submitButton) submitButton.disabled = !org; +} + adminForm.addEventListener('submit', async (e) => { e.preventDefault(); + + const { org, site } = getProjectFromUrl(); + if (!org) { + usersContainer.innerHTML = 'Select an org/site in the header to continue.'; + return; + } + usersContainer.innerHTML = 'Loading users...'; - if (site.value) { + if (site) { accessConfig.type = 'site'; const config = await getSiteAccessConfig(); if (!config) { @@ -862,9 +879,11 @@ async function init() { const neededIcons = ['user', 'edit', 'grid', 'list', 'trash']; await Promise.all(neededIcons.map(loadIcon)); - await initConfigField(); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); - if (org.value) { + const { org } = getProjectFromUrl(); + if (org) { adminForm.dispatchEvent(new Event('submit')); } } diff --git a/tools/version-admin/index.html b/tools/version-admin/index.html index d6013678..512574dd 100644 --- a/tools/version-admin/index.html +++ b/tools/version-admin/index.html @@ -9,11 +9,11 @@ > + Version Admin - @@ -39,22 +39,10 @@ Version Admin Profile - - - Organization - - - - - Site - - - - - Profile - - - + + Profile + + Fetch Versions Reset diff --git a/tools/version-admin/version-admin.css b/tools/version-admin/version-admin.css index e5ff2a56..9717a2bc 100644 --- a/tools/version-admin/version-admin.css +++ b/tools/version-admin/version-admin.css @@ -13,10 +13,6 @@ grid-template-columns: 1fr max-content; gap: var(--spacing-l); } - - .version-admin #admin-form .config-field { - grid-column: 1 / span 2; - } } #versions .button-wrapper { @@ -75,12 +71,6 @@ border-top: var(--border-s) solid var(--color-border); } -.version-admin .config-field { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: var(--spacing-m); -} - .version-admin .type-field { grid-column: 1 / -1; } diff --git a/tools/version-admin/version-admin.js b/tools/version-admin/version-admin.js index b09392a2..545f1df6 100644 --- a/tools/version-admin/version-admin.js +++ b/tools/version-admin/version-admin.js @@ -1,17 +1,14 @@ import { registerToolReady } from '../../scripts/scripts.js'; import { diffJson } from '../../vendor/diff/diff.js'; import { logResponse } from '../../blocks/console/console.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import admin from '../../scripts/helix-admin.js'; import { executeAdminRequest, AuthMode } from '../../utils/admin-request.js'; const adminForm = document.getElementById('admin-form'); const typeSelect = document.getElementById('type'); -const org = document.getElementById('org'); const profile = document.getElementById('profile'); -const site = document.getElementById('site'); const profileField = document.querySelector('.profile-field'); -const siteField = document.querySelector('.site-field'); const consoleBlock = document.querySelector('.console'); const versions = document.getElementById('versions'); const versionsTitle = document.getElementById('versions-title'); @@ -22,8 +19,9 @@ const fetchButton = document.getElementById('fetch'); const currentConfig = { type: '', versions: [], currentVersion: null }; function coords() { - const c = { org: org.value }; - if (typeSelect.value === 'site') c.site = site.value; + const { org, site } = getProjectFromUrl(); + const c = { org }; + if (typeSelect.value === 'site') c.site = site; else if (typeSelect.value === 'profile') c.profile = profile.value; return c; } @@ -40,9 +38,10 @@ function formatDate(dateString) { } async function fetchVersions() { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( () => admin.config(coords()).select('versions.json').read(), - { org: org.value, site: site.value, policy: AuthMode.PREFLIGHT_AND_RETRY }, + { org, site, policy: AuthMode.PREFLIGHT_AND_RETRY }, ); logResult(result); if (!result?.ok) return null; @@ -53,20 +52,22 @@ async function fetchVersions() { } async function fetchVersionData(versionId) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( () => admin.config(coords()).select(`versions/${versionId}.json`).read(), - { org: org.value, site: site.value }, + { org, site }, ); logResult(result); return result?.ok ? result.json() : null; } async function updateVersionName(versionId, newName) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( () => admin.config(coords()) .select(`versions/${versionId}.json`) .update(JSON.stringify({ name: newName }), { params: { name: newName } }), - { org: org.value, site: site.value }, + { org, site }, ); logResult(result); return !!result?.ok; @@ -74,18 +75,20 @@ async function updateVersionName(versionId, newName) { // POST to the config root with ?restoreVersion= and no body. async function restoreVersion(versionId) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( () => admin.config(coords()).update(null, { params: { restoreVersion: versionId } }), - { org: org.value, site: site.value }, + { org, site }, ); logResult(result); return !!result?.ok; } async function deleteVersion(versionId) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( () => admin.config(coords()).select(`versions/${versionId}.json`).remove(), - { org: org.value, site: site.value }, + { org, site }, ); logResult(result); return !!result?.ok; @@ -301,29 +304,22 @@ function updateFieldVisibility() { const selectedType = typeSelect.value; profileField.style.display = selectedType === 'profile' ? 'block' : 'none'; - siteField.style.display = selectedType === 'site' ? 'block' : 'none'; // Clear dependent fields when type changes if (selectedType !== 'profile') { profile.value = ''; } - if (selectedType !== 'site') { - site.value = ''; - } // Update required attributes profile.required = selectedType === 'profile'; - site.required = selectedType === 'site'; - - // Enable/disable site field based on type (for config utility compatibility) - site.disabled = selectedType !== 'site'; } /** * Validate form inputs */ function validateForm() { - if (!typeSelect.value || !org.value) { + const { org, site } = getProjectFromUrl(); + if (!typeSelect.value || !org) { return false; } @@ -331,19 +327,31 @@ function validateForm() { return false; } - if (typeSelect.value === 'site' && !site.value) { + if (typeSelect.value === 'site' && !site) { return false; } return true; } +function syncSubmitEnabled() { + const { org } = getProjectFromUrl(); + fetchButton.disabled = !org; +} + // Event listeners typeSelect.addEventListener('change', updateFieldVisibility); adminForm.addEventListener('submit', async (e) => { e.preventDefault(); + const { org, site } = getProjectFromUrl(); + if (!org || (typeSelect.value === 'site' && !site)) { + // eslint-disable-next-line no-alert + alert('Select an org/site in the header to continue.'); + return; + } + if (!validateForm()) { // eslint-disable-next-line no-alert alert('Please fill in all required fields.'); @@ -365,11 +373,11 @@ adminForm.addEventListener('submit', async (e) => { window.history.replaceState(null, '', url.href); // Update title - let titleText = `${org.value}`; + let titleText = `${org}`; if (typeSelect.value === 'profile') { titleText += ` / ${profile.value} (Profile)`; } else if (typeSelect.value === 'site') { - titleText += ` / ${site.value} (Site)`; + titleText += ` / ${site} (Site)`; } else { titleText += ' (Organization)'; } @@ -406,8 +414,8 @@ adminForm.addEventListener('submit', async (e) => { // Initialize async function init() { - // Initialize config field utility (handles org/site autofill from params/storage/sidekick) - await initConfigField(); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); // Initialize from URL parameters (type and profile are tool-specific) const params = new URLSearchParams(window.location.search); @@ -426,10 +434,11 @@ async function init() { } // Auto-submit if we have the required parameters - if (typeParam && org.value + const { org, site } = getProjectFromUrl(); + if (typeParam && org && ((typeParam === 'org') || (typeParam === 'profile' && profileParam) - || (typeParam === 'site' && site.value))) { + || (typeParam === 'site' && site))) { adminForm.dispatchEvent(new Event('submit')); } } diff --git a/utils/admin-request.js b/utils/admin-request.js index 1cbbddb3..f73c534f 100644 --- a/utils/admin-request.js +++ b/utils/admin-request.js @@ -1,5 +1,5 @@ import { ensureLogin } from '../blocks/profile/profile.js'; -import { updateConfig } from './config/config.js'; +import { updateStorage } from './config/config.js'; /** * Auth-handling policy for {@link executeAdminRequest}. @@ -32,9 +32,8 @@ function waitForLogin(org) { * Execute an admin API request with optional auth pre-check and 401 retry. * Returns `null` if the user fails to complete login. * - * `updateConfig()` only fires on 2xx โ a 4xx/5xx may not actually validate - * the org/site combo (e.g. 404 could mean the org doesn't exist). It's a - * no-op on pages without the org/site fields, so callers needn't opt in. + * Persistence to localStorage only fires on 2xx โ a 4xx/5xx may not actually + * validate the org/site combo (e.g. 404 could mean the org doesn't exist). * * @template {{ status: number }} T * @param {() => Promise} requestFn must be safe to invoke twice @@ -61,6 +60,6 @@ export async function executeAdminRequest(requestFn, authConfig) { result = await requestFn(); } - if (result?.ok) updateConfig(); + if (result?.ok) updateStorage(org, site); return result; } diff --git a/utils/config/config.css b/utils/config/config.css deleted file mode 100644 index 462b66d4..00000000 --- a/utils/config/config.css +++ /dev/null @@ -1,25 +0,0 @@ -.form-field.config-field { - display: grid; -} - -@media (width >= 600px) { - .form-field.config-field { - grid-template-columns: 1fr 1fr; - gap: var(--spacing-l); - } - - .form-field.config-field .form-field { - margin-top: 0; - } - - .form-field.config-field .site-field { - position: relative; - } - - .form-field.config-field .site-field::before { - content: '/'; - position: absolute; - left: calc((var(--spacing-l) + 0.5ch) / -2); - bottom: calc(var(--border-m) + 0.4em); - } -} diff --git a/utils/config/config.js b/utils/config/config.js index 84adf667..b827ef5d 100644 --- a/utils/config/config.js +++ b/utils/config/config.js @@ -1,95 +1,69 @@ -import { loadCSS } from '../../scripts/aem.js'; import { messageSidekick } from '../sidekick.js'; +const STORAGE_KEY = 'aem-projects'; + /** - * Validates the existence and type of config elements. - * @returns {Object|boolean} - Object containing config elements, or `false` if any are invalid. + * Reads the stored projects from localStorage. + * The `aem-projects` key has shape `{ orgs: string[], sites: { [org]: string[] } }`. + * @returns {{ orgs: string[], sitesByOrg: Record }} */ -function validDOM() { - const config = document.querySelector('.config-field'); - if (!config) return false; - - const params = { - org: { id: 'org', tag: 'INPUT' }, - orgList: { id: 'org-list', tag: 'DATALIST' }, - site: { id: 'site', tag: 'INPUT' }, - siteList: { id: 'site-list', tag: 'DATALIST' }, - }; +function cleanList(arr) { + return Array.isArray(arr) ? arr.filter((v) => typeof v === 'string' && v.trim() !== '') : []; +} - const els = {}; - Object.keys(params).forEach((key) => { - const { id, tag } = params[key]; - const el = config.querySelector(`#${id}`); - if (el && el.nodeName === tag) { - els[key] = el; +export function readStoredProjects() { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY)); + if (parsed && Array.isArray(parsed.orgs)) { + const orgs = cleanList(parsed.orgs); + const sitesByOrg = {}; + orgs.forEach((org) => { sitesByOrg[org] = cleanList(parsed.sites?.[org]); }); + return { orgs, sitesByOrg }; } - }); - - return Object.keys(els).length === Object.keys(params).length ? els : false; + } catch (e) { + // ignore parse errors and fall through to the default + } + return { orgs: [], sitesByOrg: {} }; } /** - * Sets field value and marks as autofilled (if it hasn't been autofilled already). - * @param {HTMLElement} field - Input field. - * @param {string} value - Value to set. - * @param {string} type - Type of autofill (params, storage, sidekick). - * @returns {string} the fields new value + * Merges sidekick-provided projects into the `aem-projects` localStorage entry. + * New orgs are appended; new sites are appended under their org. + * @param {{ org: string, site: string }[]} projects */ -function setFieldValue(field, value, type) { - if (!field.dataset.autofill) { - field.value = value; - field.dataset.autofill = type; - field.dispatchEvent(new Event('input')); +function mergeSidekickProjects(projects) { + let stored = null; + try { + stored = JSON.parse(localStorage.getItem(STORAGE_KEY)); + } catch (e) { + stored = null; } + if (!stored || !Array.isArray(stored.orgs)) { + stored = { orgs: [], sites: {} }; + } + if (!stored.sites) stored.sites = {}; - return field.value; -} - -/** - * Populates datalist with options (if options aren't already in datalist). - * @param {HTMLElement} list - Datalist element. - * @param {string[]} values - Array of values to populate as options. - */ -function populateList(list, values) { - values.forEach((value) => { - if (value && ![...list.options].some((o) => o.value === value)) { - const option = document.createElement('option'); - option.value = value; - list.append(option); - } + projects.forEach(({ org, site }) => { + if (!org) return; + if (!stored.orgs.includes(org)) stored.orgs.push(org); + if (!stored.sites[org]) stored.sites[org] = []; + if (site && !stored.sites[org].includes(site)) stored.sites[org].push(site); }); -} -/** - * Resets site datalist based on the selected org. - * @param {string} org - Organization name. - * @param {HTMLInputElement} site - Site input element. - * @param {HTMLDatalistElement} list - Site datalist element to populate with options. - */ -function resetSiteListForOrg(org, site, list) { - // clear site list - while (list.firstChild) list.removeChild(list.firstChild); - // repopulate site and site list from storage - const projects = JSON.parse(localStorage.getItem('aem-projects')); - if (projects && projects.sites && projects.sites[org]) { - site.value = projects.sites[org][0] || ''; - populateList(list, projects.sites[org]); - } + localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); } /** - * Updates URL params based on org/site data. - * @param {Array.} fields - Array of input fields. + * Loads the known org/site projects: reads localStorage, then merges in any projects + * reported by the AEM Sidekick extension (`getSites`), and returns the merged result. + * @returns {Promise<{ orgs: string[], sitesByOrg: Record }>} */ -function updateParams(fields) { - const url = new URL(window.location.href); - url.search = ''; // clear existing params - fields.forEach((field) => { - if (field.value) { - url.searchParams.set(field.id, field.value); - } - }); - window.history.replaceState({}, document.title, url.href); +export async function loadProjects() { + const sidekickProjects = await messageSidekick({ action: 'getSites' }); + if (Array.isArray(sidekickProjects) && sidekickProjects.length > 0) { + mergeSidekickProjects(sidekickProjects); + } + return readStoredProjects(); } /** @@ -97,8 +71,8 @@ function updateParams(fields) { * @param {string} org - Organization name. * @param {string} site - Site name within org. */ -function updateStorage(org, site) { - const projects = JSON.parse(localStorage.getItem('aem-projects')); +export function updateStorage(org, site) { + const projects = JSON.parse(localStorage.getItem(STORAGE_KEY)); if (projects) { // ensure org is most recent in orgs array if (projects.orgs.includes(org)) { @@ -116,154 +90,18 @@ function updateStorage(org, site) { projects.sites[org] = [site]; } } - localStorage.setItem('aem-projects', JSON.stringify(projects)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(projects)); } else { // init project org and site storage const project = { orgs: [org], sites: site ? { [org]: [site] } : {}, }; - localStorage.setItem('aem-projects', JSON.stringify(project)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(project)); } } -/** - * Adds all sidekick projects to storage data - * Since no order guarantee is made for sidekick projects, just add them at end of lists. - */ -function updateStorageFromSidekick(projects) { - let aemProjects = JSON.parse(localStorage.getItem('aem-projects')); - if (!aemProjects) { - aemProjects = { orgs: [], sites: {} }; - } - - projects.forEach(({ org, site }) => { - if (!aemProjects.orgs.includes(org)) { - aemProjects.orgs.push(org); - } - - if (!aemProjects.sites[org]) { - aemProjects.sites[org] = []; - } - - if (!aemProjects.sites[org].includes(site)) { - aemProjects.sites[org].push(site); - } - }); - - localStorage.setItem('aem-projects', JSON.stringify(aemProjects)); -} - -/** - * Updates URL parameters, local storage, and datalists based on org/site values. - */ -export function updateConfig() { - const cfg = validDOM(); - if (cfg) { - updateParams([cfg.org, cfg.site]); - updateStorage(cfg.org.value, cfg.site.value); - populateList(cfg.orgList, [cfg.org.value]); - populateList(cfg.siteList, [cfg.site.value]); - } -} - -/** - * Populates org/site fields with values from URL params. - * @param {Array.} fields - Array of input elements. - * @param {string} search - URL search string. - */ -function populateFromParams(fields, search) { - const params = new URLSearchParams(search); - if (params && params.size > 0) { - fields.forEach((field) => { - const param = params.get(field.id); - if (param) { - setFieldValue(field, param, 'params'); - } - }); - } -} - -/** - * Populates org and site fields from local storage. - */ -function populateFromStorage(org, orgList, site, siteList) { - const projects = JSON.parse(localStorage.getItem('aem-projects')); - if (projects) { - if (projects.orgs && projects.orgs[0]) { - // populate org list - const { orgs } = projects; - populateList(orgList, orgs); - // populate org field - const selectedOrg = setFieldValue(org, projects.orgs[0], 'storage'); - if (projects.sites && projects.sites[selectedOrg]) { - // populate site list - const sites = projects.sites[selectedOrg]; - populateList(siteList, sites); - // populate site field - const lastSite = sites[0]; - if (lastSite) setFieldValue(site, lastSite, 'storage'); - } - } - } -} - -/** - * Populates org field from sidekick. - */ -async function populateFromSidekick(org, orgList, site, siteList) { - const projects = await messageSidekick({ action: 'getSites' }); - if (Array.isArray(projects) && projects.length > 0) { - updateStorageFromSidekick(projects); - - const { orgs, sites } = projects.reduce((acc, part) => { - if (!acc.orgs.includes(part.org)) acc.orgs.push(part.org); - if (!acc.sites[part.org]) acc.sites[part.org] = []; - if (!acc.sites[part.org].includes(part.site)) acc.sites[part.org].push(part.site); - - return acc; - }, { orgs: [], sites: {} }); - - // populate org list - populateList(orgList, orgs); - - // populate org & site field - const lastProject = projects[0]; - const selectedOrg = setFieldValue(org, lastProject.org, 'sidekick'); - - populateList(siteList, sites[selectedOrg] || []); - if (sites[selectedOrg] && sites[selectedOrg][0]) { - setFieldValue(site, sites[selectedOrg][0], 'sidekick'); - } - } -} - -async function populateConfig(config) { - const { - org, orgList, site, siteList, - } = config; - populateFromParams([org, site], window.location.search); - populateFromStorage(org, orgList, site, siteList); - await populateFromSidekick(org, orgList, site, siteList); -} - -/** - * Loads org/site config CSS and initializes config field datalists/values. - */ -export async function initConfigField() { - const cfg = validDOM(); - - if (cfg) { - // enable site when org has value - cfg.org.addEventListener('input', () => { - cfg.site.disabled = !cfg.org.value; - }, { once: true }); - - // refresh site datalist to match org - cfg.org.addEventListener('change', (e) => { - resetSiteListForOrg(e.target.value, cfg.site, cfg.siteList); - }); - - await Promise.all([loadCSS('../../utils/config/config.css'), populateConfig(cfg)]); - } +export function getProjectFromUrl() { + const params = new URLSearchParams(window.location.search); + return { org: params.get('org') || '', site: params.get('site') || '' }; }
Load Config - Reset
- Manage the sites of an org, or a single site you have access to. + Manage the sites of an org.
- Make sure you are logged into sidekick on a project of the Organization selected -
List - Reset
Load Snapshots - Reset
Leave blank to manage org-level users, or select a site to manage site-specific access.
Fetch Users - Reset
Select an org/site in the header to continue.
Loading users...
Fetch Versions Reset diff --git a/tools/version-admin/version-admin.css b/tools/version-admin/version-admin.css index e5ff2a56..9717a2bc 100644 --- a/tools/version-admin/version-admin.css +++ b/tools/version-admin/version-admin.css @@ -13,10 +13,6 @@ grid-template-columns: 1fr max-content; gap: var(--spacing-l); } - - .version-admin #admin-form .config-field { - grid-column: 1 / span 2; - } } #versions .button-wrapper { @@ -75,12 +71,6 @@ border-top: var(--border-s) solid var(--color-border); } -.version-admin .config-field { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: var(--spacing-m); -} - .version-admin .type-field { grid-column: 1 / -1; } diff --git a/tools/version-admin/version-admin.js b/tools/version-admin/version-admin.js index b09392a2..545f1df6 100644 --- a/tools/version-admin/version-admin.js +++ b/tools/version-admin/version-admin.js @@ -1,17 +1,14 @@ import { registerToolReady } from '../../scripts/scripts.js'; import { diffJson } from '../../vendor/diff/diff.js'; import { logResponse } from '../../blocks/console/console.js'; -import { initConfigField } from '../../utils/config/config.js'; +import { getProjectFromUrl } from '../../utils/config/config.js'; import admin from '../../scripts/helix-admin.js'; import { executeAdminRequest, AuthMode } from '../../utils/admin-request.js'; const adminForm = document.getElementById('admin-form'); const typeSelect = document.getElementById('type'); -const org = document.getElementById('org'); const profile = document.getElementById('profile'); -const site = document.getElementById('site'); const profileField = document.querySelector('.profile-field'); -const siteField = document.querySelector('.site-field'); const consoleBlock = document.querySelector('.console'); const versions = document.getElementById('versions'); const versionsTitle = document.getElementById('versions-title'); @@ -22,8 +19,9 @@ const fetchButton = document.getElementById('fetch'); const currentConfig = { type: '', versions: [], currentVersion: null }; function coords() { - const c = { org: org.value }; - if (typeSelect.value === 'site') c.site = site.value; + const { org, site } = getProjectFromUrl(); + const c = { org }; + if (typeSelect.value === 'site') c.site = site; else if (typeSelect.value === 'profile') c.profile = profile.value; return c; } @@ -40,9 +38,10 @@ function formatDate(dateString) { } async function fetchVersions() { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( () => admin.config(coords()).select('versions.json').read(), - { org: org.value, site: site.value, policy: AuthMode.PREFLIGHT_AND_RETRY }, + { org, site, policy: AuthMode.PREFLIGHT_AND_RETRY }, ); logResult(result); if (!result?.ok) return null; @@ -53,20 +52,22 @@ async function fetchVersions() { } async function fetchVersionData(versionId) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( () => admin.config(coords()).select(`versions/${versionId}.json`).read(), - { org: org.value, site: site.value }, + { org, site }, ); logResult(result); return result?.ok ? result.json() : null; } async function updateVersionName(versionId, newName) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( () => admin.config(coords()) .select(`versions/${versionId}.json`) .update(JSON.stringify({ name: newName }), { params: { name: newName } }), - { org: org.value, site: site.value }, + { org, site }, ); logResult(result); return !!result?.ok; @@ -74,18 +75,20 @@ async function updateVersionName(versionId, newName) { // POST to the config root with ?restoreVersion= and no body. async function restoreVersion(versionId) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( () => admin.config(coords()).update(null, { params: { restoreVersion: versionId } }), - { org: org.value, site: site.value }, + { org, site }, ); logResult(result); return !!result?.ok; } async function deleteVersion(versionId) { + const { org, site } = getProjectFromUrl(); const result = await executeAdminRequest( () => admin.config(coords()).select(`versions/${versionId}.json`).remove(), - { org: org.value, site: site.value }, + { org, site }, ); logResult(result); return !!result?.ok; @@ -301,29 +304,22 @@ function updateFieldVisibility() { const selectedType = typeSelect.value; profileField.style.display = selectedType === 'profile' ? 'block' : 'none'; - siteField.style.display = selectedType === 'site' ? 'block' : 'none'; // Clear dependent fields when type changes if (selectedType !== 'profile') { profile.value = ''; } - if (selectedType !== 'site') { - site.value = ''; - } // Update required attributes profile.required = selectedType === 'profile'; - site.required = selectedType === 'site'; - - // Enable/disable site field based on type (for config utility compatibility) - site.disabled = selectedType !== 'site'; } /** * Validate form inputs */ function validateForm() { - if (!typeSelect.value || !org.value) { + const { org, site } = getProjectFromUrl(); + if (!typeSelect.value || !org) { return false; } @@ -331,19 +327,31 @@ function validateForm() { return false; } - if (typeSelect.value === 'site' && !site.value) { + if (typeSelect.value === 'site' && !site) { return false; } return true; } +function syncSubmitEnabled() { + const { org } = getProjectFromUrl(); + fetchButton.disabled = !org; +} + // Event listeners typeSelect.addEventListener('change', updateFieldVisibility); adminForm.addEventListener('submit', async (e) => { e.preventDefault(); + const { org, site } = getProjectFromUrl(); + if (!org || (typeSelect.value === 'site' && !site)) { + // eslint-disable-next-line no-alert + alert('Select an org/site in the header to continue.'); + return; + } + if (!validateForm()) { // eslint-disable-next-line no-alert alert('Please fill in all required fields.'); @@ -365,11 +373,11 @@ adminForm.addEventListener('submit', async (e) => { window.history.replaceState(null, '', url.href); // Update title - let titleText = `${org.value}`; + let titleText = `${org}`; if (typeSelect.value === 'profile') { titleText += ` / ${profile.value} (Profile)`; } else if (typeSelect.value === 'site') { - titleText += ` / ${site.value} (Site)`; + titleText += ` / ${site} (Site)`; } else { titleText += ' (Organization)'; } @@ -406,8 +414,8 @@ adminForm.addEventListener('submit', async (e) => { // Initialize async function init() { - // Initialize config field utility (handles org/site autofill from params/storage/sidekick) - await initConfigField(); + syncSubmitEnabled(); + window.addEventListener('tools:project-change', syncSubmitEnabled); // Initialize from URL parameters (type and profile are tool-specific) const params = new URLSearchParams(window.location.search); @@ -426,10 +434,11 @@ async function init() { } // Auto-submit if we have the required parameters - if (typeParam && org.value + const { org, site } = getProjectFromUrl(); + if (typeParam && org && ((typeParam === 'org') || (typeParam === 'profile' && profileParam) - || (typeParam === 'site' && site.value))) { + || (typeParam === 'site' && site))) { adminForm.dispatchEvent(new Event('submit')); } } diff --git a/utils/admin-request.js b/utils/admin-request.js index 1cbbddb3..f73c534f 100644 --- a/utils/admin-request.js +++ b/utils/admin-request.js @@ -1,5 +1,5 @@ import { ensureLogin } from '../blocks/profile/profile.js'; -import { updateConfig } from './config/config.js'; +import { updateStorage } from './config/config.js'; /** * Auth-handling policy for {@link executeAdminRequest}. @@ -32,9 +32,8 @@ function waitForLogin(org) { * Execute an admin API request with optional auth pre-check and 401 retry. * Returns `null` if the user fails to complete login. * - * `updateConfig()` only fires on 2xx โ a 4xx/5xx may not actually validate - * the org/site combo (e.g. 404 could mean the org doesn't exist). It's a - * no-op on pages without the org/site fields, so callers needn't opt in. + * Persistence to localStorage only fires on 2xx โ a 4xx/5xx may not actually + * validate the org/site combo (e.g. 404 could mean the org doesn't exist). * * @template {{ status: number }} T * @param {() => Promise} requestFn must be safe to invoke twice @@ -61,6 +60,6 @@ export async function executeAdminRequest(requestFn, authConfig) { result = await requestFn(); } - if (result?.ok) updateConfig(); + if (result?.ok) updateStorage(org, site); return result; } diff --git a/utils/config/config.css b/utils/config/config.css deleted file mode 100644 index 462b66d4..00000000 --- a/utils/config/config.css +++ /dev/null @@ -1,25 +0,0 @@ -.form-field.config-field { - display: grid; -} - -@media (width >= 600px) { - .form-field.config-field { - grid-template-columns: 1fr 1fr; - gap: var(--spacing-l); - } - - .form-field.config-field .form-field { - margin-top: 0; - } - - .form-field.config-field .site-field { - position: relative; - } - - .form-field.config-field .site-field::before { - content: '/'; - position: absolute; - left: calc((var(--spacing-l) + 0.5ch) / -2); - bottom: calc(var(--border-m) + 0.4em); - } -} diff --git a/utils/config/config.js b/utils/config/config.js index 84adf667..b827ef5d 100644 --- a/utils/config/config.js +++ b/utils/config/config.js @@ -1,95 +1,69 @@ -import { loadCSS } from '../../scripts/aem.js'; import { messageSidekick } from '../sidekick.js'; +const STORAGE_KEY = 'aem-projects'; + /** - * Validates the existence and type of config elements. - * @returns {Object|boolean} - Object containing config elements, or `false` if any are invalid. + * Reads the stored projects from localStorage. + * The `aem-projects` key has shape `{ orgs: string[], sites: { [org]: string[] } }`. + * @returns {{ orgs: string[], sitesByOrg: Record }} */ -function validDOM() { - const config = document.querySelector('.config-field'); - if (!config) return false; - - const params = { - org: { id: 'org', tag: 'INPUT' }, - orgList: { id: 'org-list', tag: 'DATALIST' }, - site: { id: 'site', tag: 'INPUT' }, - siteList: { id: 'site-list', tag: 'DATALIST' }, - }; +function cleanList(arr) { + return Array.isArray(arr) ? arr.filter((v) => typeof v === 'string' && v.trim() !== '') : []; +} - const els = {}; - Object.keys(params).forEach((key) => { - const { id, tag } = params[key]; - const el = config.querySelector(`#${id}`); - if (el && el.nodeName === tag) { - els[key] = el; +export function readStoredProjects() { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY)); + if (parsed && Array.isArray(parsed.orgs)) { + const orgs = cleanList(parsed.orgs); + const sitesByOrg = {}; + orgs.forEach((org) => { sitesByOrg[org] = cleanList(parsed.sites?.[org]); }); + return { orgs, sitesByOrg }; } - }); - - return Object.keys(els).length === Object.keys(params).length ? els : false; + } catch (e) { + // ignore parse errors and fall through to the default + } + return { orgs: [], sitesByOrg: {} }; } /** - * Sets field value and marks as autofilled (if it hasn't been autofilled already). - * @param {HTMLElement} field - Input field. - * @param {string} value - Value to set. - * @param {string} type - Type of autofill (params, storage, sidekick). - * @returns {string} the fields new value + * Merges sidekick-provided projects into the `aem-projects` localStorage entry. + * New orgs are appended; new sites are appended under their org. + * @param {{ org: string, site: string }[]} projects */ -function setFieldValue(field, value, type) { - if (!field.dataset.autofill) { - field.value = value; - field.dataset.autofill = type; - field.dispatchEvent(new Event('input')); +function mergeSidekickProjects(projects) { + let stored = null; + try { + stored = JSON.parse(localStorage.getItem(STORAGE_KEY)); + } catch (e) { + stored = null; } + if (!stored || !Array.isArray(stored.orgs)) { + stored = { orgs: [], sites: {} }; + } + if (!stored.sites) stored.sites = {}; - return field.value; -} - -/** - * Populates datalist with options (if options aren't already in datalist). - * @param {HTMLElement} list - Datalist element. - * @param {string[]} values - Array of values to populate as options. - */ -function populateList(list, values) { - values.forEach((value) => { - if (value && ![...list.options].some((o) => o.value === value)) { - const option = document.createElement('option'); - option.value = value; - list.append(option); - } + projects.forEach(({ org, site }) => { + if (!org) return; + if (!stored.orgs.includes(org)) stored.orgs.push(org); + if (!stored.sites[org]) stored.sites[org] = []; + if (site && !stored.sites[org].includes(site)) stored.sites[org].push(site); }); -} -/** - * Resets site datalist based on the selected org. - * @param {string} org - Organization name. - * @param {HTMLInputElement} site - Site input element. - * @param {HTMLDatalistElement} list - Site datalist element to populate with options. - */ -function resetSiteListForOrg(org, site, list) { - // clear site list - while (list.firstChild) list.removeChild(list.firstChild); - // repopulate site and site list from storage - const projects = JSON.parse(localStorage.getItem('aem-projects')); - if (projects && projects.sites && projects.sites[org]) { - site.value = projects.sites[org][0] || ''; - populateList(list, projects.sites[org]); - } + localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); } /** - * Updates URL params based on org/site data. - * @param {Array.} fields - Array of input fields. + * Loads the known org/site projects: reads localStorage, then merges in any projects + * reported by the AEM Sidekick extension (`getSites`), and returns the merged result. + * @returns {Promise<{ orgs: string[], sitesByOrg: Record }>} */ -function updateParams(fields) { - const url = new URL(window.location.href); - url.search = ''; // clear existing params - fields.forEach((field) => { - if (field.value) { - url.searchParams.set(field.id, field.value); - } - }); - window.history.replaceState({}, document.title, url.href); +export async function loadProjects() { + const sidekickProjects = await messageSidekick({ action: 'getSites' }); + if (Array.isArray(sidekickProjects) && sidekickProjects.length > 0) { + mergeSidekickProjects(sidekickProjects); + } + return readStoredProjects(); } /** @@ -97,8 +71,8 @@ function updateParams(fields) { * @param {string} org - Organization name. * @param {string} site - Site name within org. */ -function updateStorage(org, site) { - const projects = JSON.parse(localStorage.getItem('aem-projects')); +export function updateStorage(org, site) { + const projects = JSON.parse(localStorage.getItem(STORAGE_KEY)); if (projects) { // ensure org is most recent in orgs array if (projects.orgs.includes(org)) { @@ -116,154 +90,18 @@ function updateStorage(org, site) { projects.sites[org] = [site]; } } - localStorage.setItem('aem-projects', JSON.stringify(projects)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(projects)); } else { // init project org and site storage const project = { orgs: [org], sites: site ? { [org]: [site] } : {}, }; - localStorage.setItem('aem-projects', JSON.stringify(project)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(project)); } } -/** - * Adds all sidekick projects to storage data - * Since no order guarantee is made for sidekick projects, just add them at end of lists. - */ -function updateStorageFromSidekick(projects) { - let aemProjects = JSON.parse(localStorage.getItem('aem-projects')); - if (!aemProjects) { - aemProjects = { orgs: [], sites: {} }; - } - - projects.forEach(({ org, site }) => { - if (!aemProjects.orgs.includes(org)) { - aemProjects.orgs.push(org); - } - - if (!aemProjects.sites[org]) { - aemProjects.sites[org] = []; - } - - if (!aemProjects.sites[org].includes(site)) { - aemProjects.sites[org].push(site); - } - }); - - localStorage.setItem('aem-projects', JSON.stringify(aemProjects)); -} - -/** - * Updates URL parameters, local storage, and datalists based on org/site values. - */ -export function updateConfig() { - const cfg = validDOM(); - if (cfg) { - updateParams([cfg.org, cfg.site]); - updateStorage(cfg.org.value, cfg.site.value); - populateList(cfg.orgList, [cfg.org.value]); - populateList(cfg.siteList, [cfg.site.value]); - } -} - -/** - * Populates org/site fields with values from URL params. - * @param {Array.} fields - Array of input elements. - * @param {string} search - URL search string. - */ -function populateFromParams(fields, search) { - const params = new URLSearchParams(search); - if (params && params.size > 0) { - fields.forEach((field) => { - const param = params.get(field.id); - if (param) { - setFieldValue(field, param, 'params'); - } - }); - } -} - -/** - * Populates org and site fields from local storage. - */ -function populateFromStorage(org, orgList, site, siteList) { - const projects = JSON.parse(localStorage.getItem('aem-projects')); - if (projects) { - if (projects.orgs && projects.orgs[0]) { - // populate org list - const { orgs } = projects; - populateList(orgList, orgs); - // populate org field - const selectedOrg = setFieldValue(org, projects.orgs[0], 'storage'); - if (projects.sites && projects.sites[selectedOrg]) { - // populate site list - const sites = projects.sites[selectedOrg]; - populateList(siteList, sites); - // populate site field - const lastSite = sites[0]; - if (lastSite) setFieldValue(site, lastSite, 'storage'); - } - } - } -} - -/** - * Populates org field from sidekick. - */ -async function populateFromSidekick(org, orgList, site, siteList) { - const projects = await messageSidekick({ action: 'getSites' }); - if (Array.isArray(projects) && projects.length > 0) { - updateStorageFromSidekick(projects); - - const { orgs, sites } = projects.reduce((acc, part) => { - if (!acc.orgs.includes(part.org)) acc.orgs.push(part.org); - if (!acc.sites[part.org]) acc.sites[part.org] = []; - if (!acc.sites[part.org].includes(part.site)) acc.sites[part.org].push(part.site); - - return acc; - }, { orgs: [], sites: {} }); - - // populate org list - populateList(orgList, orgs); - - // populate org & site field - const lastProject = projects[0]; - const selectedOrg = setFieldValue(org, lastProject.org, 'sidekick'); - - populateList(siteList, sites[selectedOrg] || []); - if (sites[selectedOrg] && sites[selectedOrg][0]) { - setFieldValue(site, sites[selectedOrg][0], 'sidekick'); - } - } -} - -async function populateConfig(config) { - const { - org, orgList, site, siteList, - } = config; - populateFromParams([org, site], window.location.search); - populateFromStorage(org, orgList, site, siteList); - await populateFromSidekick(org, orgList, site, siteList); -} - -/** - * Loads org/site config CSS and initializes config field datalists/values. - */ -export async function initConfigField() { - const cfg = validDOM(); - - if (cfg) { - // enable site when org has value - cfg.org.addEventListener('input', () => { - cfg.site.disabled = !cfg.org.value; - }, { once: true }); - - // refresh site datalist to match org - cfg.org.addEventListener('change', (e) => { - resetSiteListForOrg(e.target.value, cfg.site, cfg.siteList); - }); - - await Promise.all([loadCSS('../../utils/config/config.css'), populateConfig(cfg)]); - } +export function getProjectFromUrl() { + const params = new URLSearchParams(window.location.search); + return { org: params.get('org') || '', site: params.get('site') || '' }; }