Skip to content
Merged

Msm #44

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
961b89f
msm app for bulk actions
ravuthu Apr 9, 2026
5a648da
msm app updated
ravuthu Apr 10, 2026
0a9f426
mobile fixes
ravuthu Apr 10, 2026
bdda385
site status dot removed
ravuthu Apr 13, 2026
5f9c1d4
some UX udpates
ravuthu Apr 13, 2026
849b603
added more actions to satellite sites
ravuthu Apr 13, 2026
c6f87c9
warning message when the entered site is not available and some UI fixes
ravuthu Apr 13, 2026
fd44d63
disable apply all when the choosen action doesn't have any applicable…
ravuthu Apr 13, 2026
3e63b51
UX fixes
ravuthu Apr 13, 2026
5d965a0
action panel min height remvoed
ravuthu Apr 14, 2026
dd8321b
Merge branch 'main' of https://github.com/aemsites/da-blog-tools into…
ravuthu Apr 24, 2026
400578d
msm app and plugin updates
ravuthu May 21, 2026
624f223
msm plugin ux update
ravuthu May 21, 2026
451a234
icons fix
ravuthu May 21, 2026
dac1ff0
plugin updates
ravuthu May 21, 2026
8c2539b
ref support added
ravuthu May 21, 2026
ce7c368
action labels updated
ravuthu May 22, 2026
e94057a
msm app and plugin updated
ravuthu May 22, 2026
6d81521
conflicts resolved
ravuthu May 22, 2026
f5c4c2e
highlight the nested page path
ravuthu May 22, 2026
dddc6f3
use the msm-app branch for app deeplink temporarily
ravuthu May 22, 2026
697fec9
msm app fixes
ravuthu May 22, 2026
82633bb
Merge branch 'msm-app' of https://github.com/aemsites/da-blog-tools i…
ravuthu May 22, 2026
56b1f59
Merge branch 'main' of https://github.com/aemsites/da-blog-tools into…
ravuthu Jun 1, 2026
629f130
Merge branch 'msm-plugin' of https://github.com/aemsites/da-blog-tool…
ravuthu Jun 1, 2026
9bd1aa3
feat(msm): rewrite plugin + app around a status-first link model with…
shsteimer Jun 4, 2026
76032f7
docs(msm): remove superseded timestamp-noise doc; ignore docs/
shsteimer Jun 4, 2026
aa91fb7
chore: gitignore local agent and playwright artifacts
shsteimer Jun 4, 2026
47cd117
fix(msm): address PR review comments
shsteimer Jun 4, 2026
529274a
feat(msm): derive app deep-link ref from plugin origin at runtime
shsteimer Jun 4, 2026
6311ca6
fix(msm): guard getAppRef against non-AEM hostnames
shsteimer Jun 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,10 @@ module.exports = {
'no-param-reassign': [2, { props: false }], // allow modifying properties of param
'import/no-cycle': 0, // Allow modules to use each other
},
overrides: [
{
files: ['test/**/*.js'],
env: { node: true },
},
],
};
3 changes: 2 additions & 1 deletion .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ jobs:
with:
node-version: 20
- run: npm ci
- run: npm run lint
- run: npm run lint
- run: npm test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ tools/node_modules/*
tools/plugins/da-chat/node_modules/*
run-publisher-local.sh
.worktrees/
.agents/diagrams/
.playwright-cli/
1 change: 1 addition & 0 deletions .hlxignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ package.json
package-lock.json
test/*
ue/models/*
docs/*
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"version": "1.0.0",
"description": "Block collection for AEM",
"scripts": {
"test": "node --test test/",
"test:watch": "node --test --watch test/",
"lint:js": "eslint .",
"lint:fix": "eslint . --fix",
"lint:css": "stylelint 'blocks/**/*.css' 'styles/*.css'",
Expand Down
119 changes: 119 additions & 0 deletions test/tools/apps/msm/core/config.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
getSourceChain,
getSiteRoles,
getAllMsmSites,
buildDescendantTree,
} from '../../../../../tools/apps/msm/core/config.js';

// global ─ apac ─ india, japan
// ├ eu ─ france, uk
// └ na ─ canada, us
const rows = [
{ base: 'global', title: 'Global' },
{ base: 'global', satellite: 'apac', title: 'APAC' },
{ base: 'global', satellite: 'eu', title: 'EU' },
{ base: 'global', satellite: 'na', title: 'NA' },
{ base: 'apac', satellite: 'india', title: 'India' },
{ base: 'apac', satellite: 'japan', title: 'Japan' },
{ base: 'eu', satellite: 'france', title: 'France' },
{ base: 'eu', satellite: 'uk', title: 'UK' },
{ base: 'na', satellite: 'canada', title: 'Canada' },
{ base: 'na', satellite: 'us', title: 'US' },
];
const config = { rows };

describe('getSourceChain', () => {
it('walks root → parent for a leaf', () => {
const chain = getSourceChain(config, 'india');
assert.deepEqual(chain.map((c) => c.site), ['global', 'apac']);
});

it('labels the root from its source-label row', () => {
const chain = getSourceChain(config, 'india');
assert.equal(chain[0].label, 'Global');
});

it('falls back to the id for intermediate sites with no source-label row', () => {
// `apac` is only ever a linked site or a source-with-linked, never a
// standalone source-label row, so its chain label is the id.
const chain = getSourceChain(config, 'india');
assert.equal(chain[1].label, 'apac');
});

it('returns an empty chain for a root site', () => {
assert.deepEqual(getSourceChain(config, 'global'), []);
});
});

describe('getSiteRoles', () => {
it('marks a root as source-only with its direct linked sites', () => {
const roles = getSiteRoles(config, 'global');
assert.ok(roles.asSource);
assert.equal(roles.asLinked, undefined);
assert.deepEqual(Object.keys(roles.asSource.linked), ['apac', 'eu', 'na']);
assert.equal(roles.asSource.linked.apac.descendantCount, 2);
});

it('marks a mid-tier site as both source and linked', () => {
const roles = getSiteRoles(config, 'apac');
assert.deepEqual(Object.keys(roles.asSource.linked), ['india', 'japan']);
assert.equal(roles.asLinked.source, 'global');
});

it('marks a leaf as linked-only', () => {
const roles = getSiteRoles(config, 'india');
assert.equal(roles.asSource, undefined);
assert.equal(roles.asLinked.source, 'apac');
});
});

describe('getAllMsmSites', () => {
it('orders breadth-first, grouped under parents, with levels', () => {
const sites = getAllMsmSites(config);
assert.deepEqual(
sites.map((s) => s.site),
['global', 'apac', 'eu', 'na', 'india', 'japan', 'france', 'uk', 'canada', 'us'],
);
assert.deepEqual(
sites.map((s) => s.level),
[0, 1, 1, 1, 2, 2, 2, 2, 2, 2],
);
});
});

describe('buildDescendantTree', () => {
it('nests direct children at each level', () => {
const tree = buildDescendantTree(rows, 'global');
assert.deepEqual(tree.map((n) => n.site), ['apac', 'eu', 'na']);
assert.deepEqual(tree[0].children.map((n) => n.site), ['india', 'japan']);
assert.deepEqual(tree[0].children[0].children, []);
});

it('returns empty for an unknown or leaf site', () => {
assert.deepEqual(buildDescendantTree(rows, 'india'), []);
assert.deepEqual(buildDescendantTree([], 'global'), []);
});
});

describe('config column compatibility', () => {
// The sheet may use the new `source`/`linked` column names instead of the
// original `base`/`satellite`; readers accept either.
const newRows = [
{ source: 'global', title: 'Global' },
{ source: 'global', linked: 'apac', title: 'APAC' },
{ source: 'apac', linked: 'india', title: 'India' },
];
const newConfig = { rows: newRows };

it('resolves roles from source/linked columns', () => {
const roles = getSiteRoles(newConfig, 'apac');
assert.deepEqual(Object.keys(roles.asSource.linked), ['india']);
assert.equal(roles.asLinked.source, 'global');
});

it('walks the source chain from source/linked columns', () => {
assert.deepEqual(getSourceChain(newConfig, 'india').map((c) => c.site), ['global', 'apac']);
});
});
104 changes: 104 additions & 0 deletions test/tools/apps/msm/core/source-tree.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
buildParentMap,
ancestorChain,
effectiveSource,
isOutOfSync,
} from '../../../../../tools/apps/msm/core/source-tree.js';

// Linked-site tree centered on root `global`:
// global ─ eu ─ france
// └ apac ─ india
const tree = [
{ siteId: 'eu', children: [{ siteId: 'france', children: [] }] },
{ siteId: 'apac', children: [{ siteId: 'india', children: [] }] },
];
const ROOT = 'global';
const keyOf = (n) => n.siteId;

describe('buildParentMap', () => {
it('maps each child to its parent and top-level nodes to the root', () => {
const pm = buildParentMap(tree, ROOT, keyOf);
assert.equal(pm.get('eu'), ROOT);
assert.equal(pm.get('apac'), ROOT);
assert.equal(pm.get('france'), 'eu');
assert.equal(pm.get('india'), 'apac');
});

it('honours the keyOf accessor (app keys nodes on `site`)', () => {
const appTree = [{ site: 'eu', children: [{ site: 'france', children: [] }] }];
const pm = buildParentMap(appTree, ROOT, (n) => n.site);
assert.equal(pm.get('france'), 'eu');
assert.equal(pm.get('eu'), ROOT);
});
});

describe('ancestorChain', () => {
const pm = buildParentMap(tree, ROOT, keyOf);

it('returns ancestors nearest-first, excluding the root sentinel', () => {
assert.deepEqual(ancestorChain('france', pm, ROOT), ['eu']);
assert.deepEqual(ancestorChain('eu', pm, ROOT), []);
});
});

describe('effectiveSource', () => {
const pm = buildParentMap(tree, ROOT, keyOf);
const rootLm = '2024-01-01';

it('resolves to the root when no ancestor is detached', () => {
const src = effectiveSource('france', pm, () => undefined, ROOT, rootLm);
assert.deepEqual(src, { site: ROOT, lm: rootLm });
});

it('resolves a direct child of the root to the root', () => {
const src = effectiveSource('eu', pm, () => undefined, ROOT, rootLm);
assert.deepEqual(src, { site: ROOT, lm: rootLm });
});

// The bug ravuthu flagged: france must pull from eu, not the root, when eu
// holds a detached copy — even though france is two levels below global.
it('resolves to the nearest detached ancestor', () => {
const lookup = (s) => (s === 'eu' ? { isDetached: true, lastModified: '2024-05-05' } : undefined);
const src = effectiveSource('france', pm, lookup, ROOT, rootLm);
assert.deepEqual(src, { site: 'eu', lm: '2024-05-05' });
});

it('prefers the nearest detached ancestor over a farther one', () => {
const deep = [{ siteId: 'eu', children: [{ siteId: 'france', children: [{ siteId: 'paris', children: [] }] }] }];
const pmDeep = buildParentMap(deep, ROOT, keyOf);
const lookup = (s) => {
if (s === 'eu') return { isDetached: true, lastModified: '2024-02-02' };
if (s === 'france') return { isDetached: true, lastModified: '2024-06-06' };
return undefined;
};
const src = effectiveSource('paris', pmDeep, lookup, ROOT, rootLm);
assert.deepEqual(src, { site: 'france', lm: '2024-06-06' });
});

it('normalises a missing lastModified to null', () => {
const lookup = (s) => (s === 'eu' ? { isDetached: true } : undefined);
const src = effectiveSource('france', pm, lookup, ROOT, rootLm);
assert.deepEqual(src, { site: 'eu', lm: null });
});
});

describe('isOutOfSync', () => {
it('is true when the source changed after the copy, beyond the lag window', () => {
assert.equal(isOutOfSync('2024-01-01T00:00:10Z', '2024-01-01T00:00:00Z', 5000), true);
});

it('is false when the source is older than the copy', () => {
assert.equal(isOutOfSync('2024-01-01', '2024-02-01', 5000), false);
});

it('is false within the publish-lag grace window', () => {
assert.equal(isOutOfSync('2024-01-01T00:00:03Z', '2024-01-01T00:00:00Z', 5000), false);
});

it('is false when either timestamp is missing', () => {
assert.equal(isOutOfSync(null, '2024-01-01', 5000), false);
assert.equal(isOutOfSync('2024-01-01', null, 5000), false);
});
});
58 changes: 58 additions & 0 deletions test/tools/apps/msm/core/status.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { getStatusConfig } from '../../../../../tools/apps/msm/core/status.js';

describe('getStatusConfig — linked (no copy)', () => {
it('is green when live is current', () => {
assert.equal(getStatusConfig({ isDetached: false, liveState: 'current' }).tip, 'Live and current');
});

it('is amber when only previewed', () => {
const cfg = getStatusConfig({ isDetached: false, liveState: 'behind', previewState: 'current' });
assert.equal(cfg.tip, 'Previewed — not yet published to live');
assert.equal(cfg.name, 'S2_Icon_AlertTriangle_20_N');
});

it('is red when nothing is published', () => {
const cfg = getStatusConfig({ isDetached: false, liveState: 'not-published', previewState: 'not-published' });
assert.equal(cfg.tip, 'Not published');
assert.equal(cfg.name, 'S2_Icon_AlertDiamond_20_N');
});

it('is red when the source changed and a publish is needed', () => {
const cfg = getStatusConfig({ isDetached: false, liveState: 'behind', previewState: 'behind' });
assert.equal(cfg.tip, 'Source changed — publish needed');
});
});

describe('getStatusConfig — detached, behind source', () => {
it('is orange when live is still current', () => {
const cfg = getStatusConfig({ isDetached: true, outOfSync: true, liveState: 'current' });
assert.equal(cfg.tip, 'Behind source — changed since last sync');
});

it('is red when not current', () => {
const cfg = getStatusConfig({ isDetached: true, outOfSync: true, liveState: 'behind' });
assert.equal(cfg.tip, 'Behind source — needs sync and publish');
});
});

describe('getStatusConfig — detached, in sync', () => {
it('is green when live is current', () => {
assert.equal(getStatusConfig({ isDetached: true, outOfSync: false, liveState: 'current' }).tip, 'Live and current');
});

it('is amber when only previewed', () => {
const cfg = getStatusConfig({
isDetached: true, outOfSync: false, liveState: 'behind', previewState: 'current',
});
assert.equal(cfg.tip, 'Previewed — not yet published to live');
});

it('is red when neither previewed nor published', () => {
const cfg = getStatusConfig({
isDetached: true, outOfSync: false, liveState: 'behind', previewState: 'behind',
});
assert.equal(cfg.tip, 'Not yet previewed or published');
});
});
Loading
Loading