diff --git a/tests/tools/page-status/status.test.js b/tests/tools/page-status/status.test.js new file mode 100644 index 00000000..614eb264 --- /dev/null +++ b/tests/tools/page-status/status.test.js @@ -0,0 +1,104 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import classifySequenceStatus from '../../../tools/page-status/status.js'; + +const EDIT = '2024-01-01T00:00:00.000Z'; +const PREVIEW = '2024-01-02T00:00:00.000Z'; +const PUBLISH = '2024-01-03T00:00:00.000Z'; + +describe('classifySequenceStatus — with source date', () => { + it('returns Not previewed when only edit exists', () => { + const { label, positive } = classifySequenceStatus(EDIT, undefined, undefined); + assert.equal(label, 'Not previewed'); + assert.equal(positive, true); + }); + + it('returns Not published when edit and preview exist in sequence', () => { + const { label, positive } = classifySequenceStatus(EDIT, PREVIEW, undefined); + assert.equal(label, 'Not published'); + assert.equal(positive, true); + }); + + it('returns Pending changes when edit is newer than preview (unpublished edit)', () => { + const { label, positive } = classifySequenceStatus(PUBLISH, EDIT, undefined); + assert.equal(label, 'Pending changes'); + assert.equal(positive, true); + }); + + it('returns Current when edit → preview → publish are in sequence', () => { + const { label, positive } = classifySequenceStatus(EDIT, PREVIEW, PUBLISH); + assert.equal(label, 'Current'); + assert.equal(positive, true); + }); + + it('returns Pending changes when preview is newer than publish', () => { + const { label, positive } = classifySequenceStatus(EDIT, PUBLISH, PREVIEW); + assert.equal(label, 'Pending changes'); + assert.equal(positive, true); + }); +}); + +describe('classifySequenceStatus — no source for authored content', () => { + [undefined, null, 'not-a-date'].forEach((noSource) => { + const label = noSource === null ? 'null' : String(noSource); + + it(`preserves No source warning when only preview exists [edit=${label}]`, () => { + const result = classifySequenceStatus(noSource, PREVIEW, undefined); + assert.equal(result.label, 'No source'); + assert.equal(result.positive, false); + }); + + it(`preserves No source warning when preview and publish exist [edit=${label}]`, () => { + const result = classifySequenceStatus(noSource, PREVIEW, PUBLISH); + assert.equal(result.label, 'No source'); + assert.equal(result.positive, false); + }); + }); +}); + +describe('classifySequenceStatus — no source (BYOM, issue #340)', () => { + // Both undefined (absent field) and null (explicit API value) must be treated as no source. + [undefined, null].forEach((noSource) => { + const label = noSource === null ? 'null' : 'undefined'; + + it(`returns No source (negative) when nothing exists [edit=${label}]`, () => { + const result = classifySequenceStatus(noSource, undefined, undefined, { + allowMissingSourceDate: true, + }); + assert.equal(result.label, 'No source'); + assert.equal(result.positive, false); + }); + + it(`returns Not published (positive) when only preview exists [edit=${label}]`, () => { + const result = classifySequenceStatus(noSource, PREVIEW, undefined, { + allowMissingSourceDate: true, + }); + assert.equal(result.label, 'Not published'); + assert.equal(result.positive, true); + }); + + it(`returns Current (positive) when only publish exists [edit=${label}]`, () => { + const result = classifySequenceStatus(noSource, undefined, PUBLISH, { + allowMissingSourceDate: true, + }); + assert.equal(result.label, 'Current'); + assert.equal(result.positive, true); + }); + + it(`returns Current (positive) when preview and publish are in sequence [edit=${label}]`, () => { + const result = classifySequenceStatus(noSource, PREVIEW, PUBLISH, { + allowMissingSourceDate: true, + }); + assert.equal(result.label, 'Current'); + assert.equal(result.positive, true); + }); + + it(`returns Pending changes (positive) when preview is newer than publish [edit=${label}]`, () => { + const result = classifySequenceStatus(noSource, PUBLISH, PREVIEW, { + allowMissingSourceDate: true, + }); + assert.equal(result.label, 'Pending changes'); + assert.equal(result.positive, true); + }); + }); +}); diff --git a/tools/page-status/scripts.js b/tools/page-status/scripts.js index 6df71010..8cfa079b 100644 --- a/tools/page-status/scripts.js +++ b/tools/page-status/scripts.js @@ -3,6 +3,7 @@ import { decorateIcons } from '../../scripts/aem.js'; import { initConfigField, updateConfig } from '../../utils/config/config.js'; import { ensureLogin } from '../../blocks/profile/profile.js'; import loadingMessages from './loading-messages.js'; +import classifySequenceStatus from './status.js'; const FORM = document.getElementById('status-form'); const TABLE = document.querySelector('table'); @@ -318,38 +319,21 @@ function buildLink(text, url, path) { * @param {string} publish - Publish date. * @returns {HTMLSpanElement} Status light element indicating status and sequence. */ -function buildSequenceStatus(edit, preview, publish) { - // check if a date is valid - const date = (d) => !Number.isNaN(d.getTime()); - const editDate = new Date(edit); - const previewDate = new Date(preview); - const publishDate = new Date(publish); - const inSequence = (editDate <= previewDate && previewDate <= publishDate); +function buildSequenceStatus(edit, preview, publish, options) { + const { label, positive } = classifySequenceStatus(edit, preview, publish, options); const span = document.createElement('span'); span.className = 'status-light'; - let status; - if (!date(editDate)) { - status = 'No source'; - span.classList.add('negative'); - } else if (date(editDate) && !date(previewDate) && !date(publishDate)) { - status = 'Not previewed'; - span.classList.add('positive'); - } else if ( - date(editDate) - && date(previewDate) - && !date(publishDate) - && editDate <= previewDate - ) { - status = 'Not published'; - span.classList.add('positive'); - } else { - status = inSequence ? 'Current' : 'Pending changes'; - span.classList.add('positive'); - } - span.textContent = status; + span.classList.add(positive ? 'positive' : 'negative'); + span.textContent = label; return span; } +function isBYOMContentSource(contentSource) { + const { type, url } = contentSource || {}; + if (type !== 'markup') return false; + return !url?.startsWith('https://content.da.live') && !url?.includes('adobeaemcloud'); +} + function buildRedirectIcon(redirectLocation) { if (!redirectLocation) return ''; @@ -387,7 +371,7 @@ function buildRedirectIcon(redirectLocation) { * @param {string} resource.path - The resource's path. * @returns {HTMLTableRowElement|null} `` element for resource, or `null` if no `path`. */ -function buildResource(resource, live, preview) { +function buildResource(resource, live, preview, options) { const { path, sourceLastModified, @@ -403,6 +387,7 @@ function buildResource(resource, live, preview) { sourceLastModified, previewLastModified, publishLastModified, + options, ); const cols = [ path, @@ -430,9 +415,9 @@ function buildResource(resource, live, preview) { * @param {string} live - Base URL for live links. * @param {string} preview - Base URL for preview links. */ -function displayResources(resources, live, preview) { +function displayResources(resources, live, preview, options) { resources.forEach((resource) => { - const row = buildResource(resource, live, preview); + const row = buildResource(resource, live, preview, options); if (row) RESULTS.append(row); }); } @@ -476,6 +461,21 @@ async function validateHosts(org, site) { return { live, preview }; } +async function fetchPageStatusOptions(org, site) { + let res; + try { + res = await fetch(`https://admin.hlx.page/config/${org}/sites/${site}.json`); + } catch { + return {}; + } + + if (!res.ok) return {}; + const config = await res.json(); + return { + allowMissingSourceDate: isBYOMContentSource(config.content?.source), + }; +} + /** * Fetches job URL for page status admin operation. * @param {string} org - Organization name. @@ -552,12 +552,12 @@ async function runJob(url, retry = 10000) { * @param {string} preview - Base URL for preview resources. * @returns {Promise<>} Promise that resolves once job has run and results are displayed. */ -async function runAndDisplayJob(jobUrl, live, preview) { +async function runAndDisplayJob(jobUrl, live, preview, options) { const paths = await runJob(jobUrl); if (!paths || paths.length === 0) { throw new Error('No page status data found.'); } - displayResources(paths, live, preview); + displayResources(paths, live, preview, options); updateTableDisplay('results'); enableActionButtons(); } @@ -604,11 +604,14 @@ async function runFromParams(search) { // initial setup setupJob(FORM, FORM.querySelector('button')); // fetch host config - const { live, preview } = await validateHosts(org, site); + const [{ live, preview }, options] = await Promise.all([ + validateHosts(org, site), + fetchPageStatusOptions(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); + await runAndDisplayJob(jobUrl, live, preview, options); updateJobParam(job); } catch (error) { updateTableError('Job'); @@ -654,12 +657,15 @@ async function init() { setupJob(target, submitter); const { path } = data; // fetch host config - const { live, preview } = await validateHosts(org, site); + const [{ live, preview }, options] = await Promise.all([ + validateHosts(org, site), + fetchPageStatusOptions(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.'); - await runAndDisplayJob(jobUrl, live, preview); + await runAndDisplayJob(jobUrl, live, preview, options); } catch (error) { updateTableError('Job'); removeJobParam(); diff --git a/tools/page-status/status.js b/tools/page-status/status.js new file mode 100644 index 00000000..4b05be69 --- /dev/null +++ b/tools/page-status/status.js @@ -0,0 +1,47 @@ +/** + * Classifies the sequence status for a resource based on edit, preview, and publish dates. + * Returns a label and whether the status is considered positive (vs. negative/actionable). + * @param {string|null|undefined} edit - Source/edit last-modified date. + * @param {string|null|undefined} preview - Preview last-modified date. + * @param {string|null|undefined} publish - Publish last-modified date. + * @param {Object} [options] - Classification options. + * @param {boolean} [options.allowMissingSourceDate=false] - Whether missing source dates + * are expected. + * @returns {{ label: string, positive: boolean }} + */ +export default function classifySequenceStatus(edit, preview, publish, options = {}) { + const { allowMissingSourceDate = false } = options; + const valid = (d) => !Number.isNaN(d.getTime()); + // Treat null and undefined as absent — new Date(null) yields epoch which is a valid date. + const editDate = new Date(edit ?? NaN); + const previewDate = new Date(preview ?? NaN); + const publishDate = new Date(publish ?? NaN); + + if (!valid(editDate)) { + if (!allowMissingSourceDate) { + return { label: 'No source', positive: false }; + } + + // BYOM pages have no sourceLastModified from the admin API. Classify by + // preview/publish state rather than always returning a negative status. + if (!valid(previewDate) && !valid(publishDate)) { + return { label: 'No source', positive: false }; + } + if (valid(previewDate) && !valid(publishDate)) { + return { label: 'Not published', positive: true }; + } + if (!valid(previewDate) && valid(publishDate)) { + return { label: 'Current', positive: true }; + } + return { label: previewDate <= publishDate ? 'Current' : 'Pending changes', positive: true }; + } + + if (!valid(previewDate) && !valid(publishDate)) { + return { label: 'Not previewed', positive: true }; + } + if (valid(previewDate) && !valid(publishDate) && editDate <= previewDate) { + return { label: 'Not published', positive: true }; + } + const inSequence = editDate <= previewDate && previewDate <= publishDate; + return { label: inSequence ? 'Current' : 'Pending changes', positive: true }; +}