feat(org-chart): support standalone React integration and update guides
This commit is contained in:
1 parent
5acaf8a295
commit
b637f86631
19 files changed
+1455
-64
No files matched your search
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
// Target only the affected existing Story and the unchanged Fixed composition.
|
||||
const evidence = fileURLToPath(new URL('../test-results/org-chart-minimal-install/', import.meta.url));
|
||||
const root = path.join(evidence, 'storybook');
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
|
||||
const server = http.createServer(async (request, response) => {
|
||||
const filename = path.resolve(root, '.' + decodeURIComponent(new URL(request.url, 'http://localhost').pathname));
|
||||
if (!filename.startsWith(root + path.sep)) { response.writeHead(403).end(); return; }
|
||||
try { const bytes = await readFile(filename); response.writeHead(200, { 'Content-Type': mime[path.extname(filename)] || 'application/octet-stream' }).end(bytes); }
|
||||
catch { response.writeHead(404).end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
assert.ok(process.env.ORGCHART_PLAYWRIGHT_MODULE, 'Set ORGCHART_PLAYWRIGHT_MODULE');
|
||||
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href);
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.ORGCHART_CHROME || 'C:/Program Files/Google/Chrome/Application/chrome.exe' });
|
||||
const errors = [], results = [];
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||
for (const id of ['orgchart--expanded-org-chart', 'orgchart--full-composition']) {
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}/iframe.html?id=${id}&viewMode=story`);
|
||||
const card = page.locator('.hm-orgchart-employee').first();
|
||||
await card.waitFor(); await card.focus(); await page.keyboard.press('Enter');
|
||||
const hm = page.locator('[data-component="HMOrgChartEmployeeDialog"]');
|
||||
const core = page.locator('dialog.hm-orgchart-employee-dialog');
|
||||
if (id.includes('expanded')) {
|
||||
await hm.waitFor({ state: 'visible' });
|
||||
assert.equal(await core.evaluate(e => e.open), false);
|
||||
await page.screenshot({ path: path.join(evidence, 'storybook-expanded-hm.png') });
|
||||
await page.keyboard.press('Escape'); await hm.waitFor({ state: 'hidden' });
|
||||
assert.equal(await card.evaluate(e => e.getRootNode().activeElement === e), true);
|
||||
const trigger = page.getByRole('combobox', { name: '직원 정보 팝업' });
|
||||
await trigger.click(); await page.getByRole('option', { name: 'Core 기본 팝업', exact: true }).click();
|
||||
// Wait for Radix's exiting Select portal/focus restoration before opening
|
||||
// another modal with the keyboard. External consumers have no Select here.
|
||||
await page.getByRole('listbox').waitFor({ state: 'detached' });
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await card.focus(); await page.keyboard.press('Enter');
|
||||
await core.waitFor({ state: 'visible' });
|
||||
assert.equal(await hm.count(), 0);
|
||||
assert.equal(await core.evaluate(e => e.contains(e.getRootNode().activeElement)), true, 'Core modal receives focus after Select closes');
|
||||
await page.keyboard.press('Escape'); await core.waitFor({ state: 'hidden' });
|
||||
results.push({ story: id, status: 'PASS', checks: ['Existing default HM Dialog', 'Escape and focus restoration', 'Existing selector switches to Core popup'] });
|
||||
} else {
|
||||
await core.waitFor({ state: 'visible' }); assert.equal(await hm.count(), 0);
|
||||
await page.screenshot({ path: path.join(evidence, 'storybook-fixed-core.png') });
|
||||
await page.keyboard.press('Escape'); await core.waitFor({ state: 'hidden' });
|
||||
results.push({ story: id, status: 'PASS', checks: ['Existing Fixed render and Core popup', 'Escape'] });
|
||||
}
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(path.join(evidence, 'storybook-browser.json'), JSON.stringify({ status: 'PASS', results, errors }, null, 2));
|
||||
console.log(JSON.stringify({ status: 'PASS', results, errors }));
|
||||
} finally {
|
||||
await browser.close(); await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdir, mkdtemp, readFile, writeFile, copyFile, access } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
// External consumers are outside the checkout and its node_modules ancestors.
|
||||
// Preserve the temp projects, lockfiles and logs for inspection; never clean them.
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const evidence = path.join(root, 'test-results/org-chart-minimal-install');
|
||||
await mkdir(evidence, { recursive: true });
|
||||
const npmCli = process.env.ORGCHART_NPM_CLI || path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js');
|
||||
const externalRoot = process.env.ORGCHART_CONSUMER_ROOT || await mkdtemp(path.join(tmpdir(), 'hm-orgchart-minimal-'));
|
||||
const projects = Object.fromEntries(['A', 'B', 'C'].map(name => [name, path.join(externalRoot, name)]));
|
||||
await writeFile(path.join(evidence, 'projects.json'), JSON.stringify({ externalRoot, projects }, null, 2));
|
||||
console.log(`External projects: ${externalRoot}`);
|
||||
|
||||
function run(args, cwd, log) {
|
||||
const result = spawnSync(process.execPath, args, { cwd, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 });
|
||||
return { status: result.status, output: `${result.stdout ?? ''}${result.stderr ?? ''}`, error: result.error, log };
|
||||
}
|
||||
async function npm(args, cwd, log) {
|
||||
const result = run([npmCli, ...args], cwd, log);
|
||||
await writeFile(path.join(evidence, `${log}.log`), result.output);
|
||||
assert.equal(result.status, 0, `${log} failed: ${result.output.slice(-5000)}`);
|
||||
console.log(`${log}: PASS`);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (process.env.ORGCHART_SKIP_PACK !== '1') for (const name of ['org-chart', 'ui', 'org-chart-react']) {
|
||||
await npm(['pack', '--workspace', `@hm-design/${name}`, '--pack-destination', evidence], root, `pack-${name}`);
|
||||
}
|
||||
const tarball = name => `file:${path.join(evidence, `hm-design-${name}-0.1.0.tgz`).replaceAll('\\', '/')}`;
|
||||
const versions = { react: '19.3.0', 'react-dom': '19.3.0' };
|
||||
for (const [name, directory] of Object.entries(projects)) {
|
||||
await mkdir(directory, { recursive: true });
|
||||
const manifest = {
|
||||
name: `hm-orgchart-minimal-${name.toLowerCase()}`, private: true, type: 'module',
|
||||
scripts: { typecheck: 'tsc --noEmit', build: 'npm run typecheck && vite build' },
|
||||
dependencies: { '@hm-design/org-chart': tarball('org-chart'), ...(name === 'A' ? {} : { ...versions, '@hm-design/org-chart-react': tarball('org-chart-react') }), ...(name === 'C' ? { '@hm-design/ui': tarball('ui'), tailwindcss: '4.3.3' } : {}) },
|
||||
devDependencies: { typescript: '7.0.2', vite: '8.3.0', ...(name === 'A' ? {} : { '@types/react': '19.3.0', '@types/react-dom': '19.3.0' }), ...(name === 'C' ? { '@tailwindcss/vite': '4.3.3' } : {}) },
|
||||
};
|
||||
await writeFile(path.join(directory, 'package.json'), JSON.stringify(manifest, null, 2));
|
||||
await writeFile(path.join(directory, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: 'ES2022', lib: ['ES2022', 'DOM', 'DOM.Iterable'], module: 'ESNext', moduleResolution: 'Bundler', jsx: 'react-jsx', strict: true, skipLibCheck: false, noEmit: true, types: ['vite/client'], resolveJsonModule: true }, include: ['main.ts', 'main.tsx'] }, null, 2));
|
||||
await copyFile(path.join(root, 'examples/org-chart/data-expanded-small.json'), path.join(directory, 'data.json'));
|
||||
await writeFile(path.join(directory, 'index.html'), '<!doctype html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="icon" href="data:"><title>OrgChart package verification</title></head><body><div id="app"></div><script type="module" src="/main.' + (name === 'A' ? 'ts' : 'tsx') + '"></script></body></html>');
|
||||
const shared = `import { createOrgChart, JsonOrgChartDataSource } from '@hm-design/org-chart';
|
||||
import engine from '@hm-design/org-chart/engine.css?url';
|
||||
import core from '@hm-design/org-chart/style.css?url';
|
||||
import data from './data.json';
|
||||
const source = new JsonOrgChartDataSource(data);
|
||||
const stylesheetUrls = { engine, core };
|
||||
const params = new URLSearchParams(location.search);
|
||||
const layoutMode = params.get('layout') === 'fixed' ? 'fixed' : 'auto';
|
||||
const events: unknown[] = [], errors: string[] = [];
|
||||
const onStateChange = (state: import('@hm-design/org-chart').OrgChartState) => { events.push(state); document.documentElement.dataset.chartState = state.status; };
|
||||
const onLifecycleError = (error: unknown) => errors.push(String(error));
|
||||
Object.assign(window, { verification: { events, errors, dataBefore: JSON.stringify(data), data } });
|
||||
document.body.style.margin = '0';
|
||||
`;
|
||||
if (name === 'A') {
|
||||
await writeFile(path.join(directory, 'main.ts'), shared + `const handle = createOrgChart({dataSource: source, stylesheetUrls, layoutMode, displayMode: 'readOnly', onStateChange});
|
||||
Object.assign(window, { handle });
|
||||
await handle.mount(document.getElementById('app')!);
|
||||
window.addEventListener('pagehide', () => handle.destroy());
|
||||
`);
|
||||
} else {
|
||||
await writeFile(path.join(directory, 'main.tsx'), shared.replace('createOrgChart, ', '') + `import { StrictMode, useLayoutEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { OrgChart as BaseOrgChart, type OrgChartEmployeePopupProps, type OrgChartProps } from '@hm-design/org-chart-react';
|
||||
${name === 'C' ? "import { OrgChart as HMOrgChart } from '@hm-design/org-chart-react/hm-dialog';\nimport './hm.css';" : ''}
|
||||
function CustomPopup({selection, onClose}: OrgChartEmployeePopupProps) {
|
||||
useLayoutEffect(() => { if (!selection) return; const trigger = selection.context.trigger; return () => { if (trigger.isConnected) trigger.focus(); }; }, [selection]);
|
||||
return selection ? <div role="dialog" aria-modal="true" aria-label="Custom employee" data-component="CustomPopup"><p>{selection.employee.name}</p><button onClick={onClose}>Close custom</button></div> : null;
|
||||
}
|
||||
function App() {
|
||||
const [mode, setMode] = useState<NonNullable<OrgChartProps['employeePopup']>>((params.get('popup') as OrgChartProps['employeePopup']) || 'core');
|
||||
const [callback, setCallback] = useState(false);
|
||||
const Chart = ${name === 'C' ? "params.get('entry') === 'base' ? BaseOrgChart : HMOrgChart" : 'BaseOrgChart'};
|
||||
Object.assign(window, { setPopup: setMode, setCallback, source });
|
||||
return <Chart dataSource={source} stylesheetUrls={stylesheetUrls} layoutMode={layoutMode} displayMode="readOnly"
|
||||
employeePopup={mode} employeePopupAdapter={mode === 'adapter' || params.get('adapter') === 'supplied' ? CustomPopup : undefined}
|
||||
onEmployeeClick={callback ? (employee, context) => { Object.assign(window, { clicked: { employee, organization: context.organization } }); } : undefined}
|
||||
onStateChange={onStateChange} onLifecycleError={onLifecycleError} />;
|
||||
}
|
||||
createRoot(document.getElementById('app')!).render(<StrictMode><App /></StrictMode>);
|
||||
`);
|
||||
if (name === 'C') {
|
||||
await writeFile(path.join(directory, 'hm.css'), '@import "tailwindcss";\n@import "@hm-design/ui/theme.css";\n@source "./node_modules/@hm-design/ui/dist";\n');
|
||||
await writeFile(path.join(directory, 'vite.config.mjs'), "import tailwindcss from '@tailwindcss/vite'; export default { plugins: [tailwindcss()] };\n");
|
||||
}
|
||||
}
|
||||
await npm(['install', ...(process.env.ORGCHART_OFFLINE === '1' ? ['--offline'] : []), '--no-audit', '--no-fund'], directory, `install-${name}`);
|
||||
const require = createRequire(path.join(directory, 'package.json'));
|
||||
assert.ok(!require.resolve('@hm-design/org-chart').includes(root));
|
||||
if (name !== 'C') {
|
||||
assert.throws(() => require.resolve('@hm-design/ui'), { code: 'MODULE_NOT_FOUND' });
|
||||
await assert.rejects(access(path.join(directory, 'node_modules/@hm-design/ui')));
|
||||
}
|
||||
if (name === 'A') {
|
||||
assert.throws(() => require.resolve('react'), { code: 'MODULE_NOT_FOUND' });
|
||||
assert.throws(() => require.resolve('@hm-design/org-chart-react'), { code: 'MODULE_NOT_FOUND' });
|
||||
}
|
||||
await npm(['ls', '--all', '--json'], directory, `dependencies-${name}`);
|
||||
await npm(['run', 'build'], directory, `build-${name}`);
|
||||
}
|
||||
// Traverse the shipped default runtime and declaration graph, including shared chunks.
|
||||
const baseDir = path.join(projects.B, 'node_modules/@hm-design/org-chart-react/dist');
|
||||
const graph = [];
|
||||
async function inspect(file) {
|
||||
if (graph.includes(file)) return;
|
||||
graph.push(file);
|
||||
const text = await readFile(path.join(baseDir, file), 'utf8');
|
||||
assert.ok(!text.includes('@hm-design/ui') && !text.includes('HMEmployeeDialog') && !text.includes('hm-dialog.js'), `UI leaked into ${file}`);
|
||||
for (const match of text.matchAll(/(?:from\s*|import\s*\()?["'](\.\/[^"']+\.(?:js|ts))["']/g)) {
|
||||
let relative = match[1].slice(2);
|
||||
if (file.endsWith('.d.ts') && relative.endsWith('.js')) relative = relative.slice(0, -3) + '.d.ts';
|
||||
await inspect(relative);
|
||||
}
|
||||
}
|
||||
await inspect('index.js'); await inspect('index.d.ts');
|
||||
await writeFile(path.join(evidence, 'default-entry-graph.json'), JSON.stringify({ status: 'PASS', files: graph }, null, 2));
|
||||
|
||||
const playwrightPath = process.env.ORGCHART_PLAYWRIGHT_MODULE;
|
||||
assert.ok(playwrightPath, 'Set ORGCHART_PLAYWRIGHT_MODULE to an existing Playwright entry (test-only).');
|
||||
const { chromium } = await import(pathToFileURL(playwrightPath).href);
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.ORGCHART_CHROME || 'C:/Program Files/Google/Chrome/Application/chrome.exe' });
|
||||
const results = [], servers = [], runtimeErrors = [];
|
||||
const check = (name, detail = null) => { results.push({ name, status: 'PASS', detail }); console.log(`browser: ${name}: PASS`); };
|
||||
try {
|
||||
for (const [name, directory] of Object.entries(projects)) {
|
||||
const vite = await import(pathToFileURL(createRequire(path.join(directory, 'package.json')).resolve('vite')).href);
|
||||
const server = await vite.preview({ root: directory, preview: { host: '127.0.0.1', port: 0 }, logLevel: 'silent' });
|
||||
servers.push(server);
|
||||
const url = server.resolvedUrls.local[0];
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
page.on('pageerror', error => runtimeErrors.push({ project: name, message: error.message }));
|
||||
page.on('console', message => { if (message.type() === 'error') runtimeErrors.push({ project: name, message: message.text() }); });
|
||||
page.on('response', response => { if (response.status() >= 400) runtimeErrors.push({ project: name, url: response.url(), status: response.status() }); });
|
||||
async function ready(query) {
|
||||
await page.goto(url + query);
|
||||
await page.waitForFunction(() => document.documentElement.dataset.chartState === 'ready');
|
||||
await page.locator('.hm-orgchart-employee').first().waitFor();
|
||||
assert.equal(await page.locator('.hm-orgchart-host').count(), 1);
|
||||
assert.equal(await page.evaluate(() => JSON.stringify(window.verification.data) === window.verification.dataBefore), true);
|
||||
}
|
||||
const card = () => page.locator('.hm-orgchart-employee').first();
|
||||
const coreDialog = () => page.locator('dialog.hm-orgchart-employee-dialog');
|
||||
const hmDialog = () => page.locator('[data-component="HMOrgChartEmployeeDialog"]');
|
||||
async function corePopup(label) {
|
||||
await card().focus(); await page.keyboard.press('Enter');
|
||||
await coreDialog().waitFor({ state: 'visible' });
|
||||
assert.equal(await coreDialog().evaluate(e => e.open), true);
|
||||
assert.equal(await hmDialog().count(), 0);
|
||||
await page.keyboard.press('Escape');
|
||||
await coreDialog().waitFor({ state: 'hidden' });
|
||||
assert.equal(await card().evaluate(e => e.getRootNode().activeElement === e), true);
|
||||
await card().click(); await coreDialog().waitFor({ state: 'visible' });
|
||||
await page.getByRole('button', { name: '직원 정보 닫기', exact: true }).click();
|
||||
await coreDialog().waitFor({ state: 'hidden' });
|
||||
check(label);
|
||||
}
|
||||
for (const layout of ['auto', 'fixed']) {
|
||||
await ready(`?layout=${layout}`);
|
||||
await corePopup(`${name} ${layout}: render, Core popup, keyboard, close, focus`);
|
||||
assert.deepEqual(await page.evaluate(() => window.verification.errors), []);
|
||||
await page.screenshot({ path: path.join(evidence, `${name}-${layout}.png`) });
|
||||
}
|
||||
if (name !== 'A') {
|
||||
// Presence of a custom adapter or the HM entry never automatically opts in.
|
||||
await ready('?layout=auto&adapter=supplied');
|
||||
await corePopup(`${name}: supplied adapter with default mode retains Core`);
|
||||
await page.evaluate(() => window.setPopup('adapter'));
|
||||
await card().click();
|
||||
await page.locator('[data-component="CustomPopup"]').waitFor({ state: 'visible' });
|
||||
assert.equal(await coreDialog().evaluate(e => e.open), false);
|
||||
await page.getByRole('button', { name: 'Close custom' }).click();
|
||||
await page.locator('[data-component="CustomPopup"]').waitFor({ state: 'detached' });
|
||||
check(`${name}: custom adapter without Core/data changes`);
|
||||
await page.evaluate(() => window.setCallback(true));
|
||||
await card().click();
|
||||
await page.waitForFunction(() => !!window.clicked);
|
||||
assert.equal(await page.locator('[data-component="CustomPopup"]').count(), 0);
|
||||
check(`${name}: onEmployeeClick takes precedence`);
|
||||
}
|
||||
if (name === 'B') {
|
||||
await ready('?layout=auto&popup=hm-dialog');
|
||||
await corePopup('B: missing HM adapter safely retains Core popup');
|
||||
assert.ok((await page.evaluate(() => window.verification.errors)).every(e => e.includes('popup adapter is missing')));
|
||||
}
|
||||
if (name === 'C') {
|
||||
await ready('?layout=auto&entry=base');
|
||||
await corePopup('C: UI installed, base entry still uses Core');
|
||||
await ready('?layout=auto&popup=hm-dialog');
|
||||
await card().focus(); await page.keyboard.press('Enter');
|
||||
await hmDialog().waitFor({ state: 'visible' });
|
||||
assert.equal(await coreDialog().evaluate(e => e.open), false);
|
||||
const employeeName = await card().locator('.hm-orgchart-person-name').textContent();
|
||||
assert.ok((await hmDialog().textContent()).includes(employeeName.trim()));
|
||||
await page.screenshot({ path: path.join(evidence, 'C-hm-dialog.png') });
|
||||
await page.keyboard.press('Escape'); await hmDialog().waitFor({ state: 'hidden' });
|
||||
assert.equal(await card().evaluate(e => e.getRootNode().activeElement === e), true);
|
||||
await card().click(); await hmDialog().waitFor({ state: 'visible' });
|
||||
await hmDialog().getByRole('button', { name: '닫기', exact: true }).click(); await hmDialog().waitFor({ state: 'hidden' });
|
||||
check('C: HM Dialog employee data, Enter, Escape, close, focus restoration');
|
||||
// Switch ownership without rebuilding Core, with an open HM popup.
|
||||
await card().click(); await hmDialog().waitFor({ state: 'visible' });
|
||||
await page.evaluate(() => { window.savedHost = document.querySelector('.hm-orgchart-host'); window.setPopup('core'); });
|
||||
await hmDialog().waitFor({ state: 'hidden' });
|
||||
assert.equal(await page.evaluate(() => window.savedHost === document.querySelector('.hm-orgchart-host')), true);
|
||||
await corePopup('C: HM to Core switch retains same Core instance');
|
||||
assert.deepEqual(await page.evaluate(() => window.verification.errors), []);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
assert.deepEqual(runtimeErrors, []);
|
||||
check('No browser exceptions, console errors or HTTP failures', runtimeErrors);
|
||||
await writeFile(path.join(evidence, 'browser.json'), JSON.stringify({ status: 'PASS', browser: browser.version(), results, runtimeErrors }, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await Promise.all(servers.map(server => new Promise(resolve => server.httpServer.close(resolve))));
|
||||
}
|
||||
console.log(`PASS: ${results.length} targeted browser checks; artifacts: ${evidence}`);
|
||||
Reference in new issue
Block a user