feat(org-chart): finalize chart features and AI generation rules
This commit is contained in:
1 parent
7614507583
commit
d0828a7ff4
80 files changed
+8152
-221
No files matched your search
@@ -1,14 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import { lazy, Suspense, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createOrgChart } from '@hm-design/org-chart';
|
||||
import type { OrgChartDataSource, OrgChartHandle } from '@hm-design/org-chart';
|
||||
import type { OrgChartAutoLayoutOptions, OrgChartDataSource, OrgChartHandle, OrgChartInteractionOptions, OrgChartOptions } from '@hm-design/org-chart';
|
||||
import type { OrgChartProps } from './types.js';
|
||||
import type { EmployeeDialogSelection } from './HMEmployeeDialog.js';
|
||||
|
||||
const HMEmployeeDialog = lazy(() => import('./HMEmployeeDialog.js'));
|
||||
|
||||
function annotateEmployeeCards(shadow: ShadowRoot, popup: NonNullable<OrgChartProps['employeePopup']>, customCallback: boolean) {
|
||||
const actionable = customCallback || popup !== 'none';
|
||||
for (const card of shadow.querySelectorAll<HTMLElement>('.hm-orgchart-employee')) {
|
||||
const name = card.querySelector('.hm-orgchart-person-name')?.textContent ?? '';
|
||||
const position = card.querySelector('.hm-orgchart-person-role')?.textContent ?? '';
|
||||
// Auto cards also provide keyboard movement when the information popup is
|
||||
// disabled; popup ownership must not remove that Core keyboard entry point.
|
||||
card.tabIndex = 0;
|
||||
if (actionable) card.setAttribute('role', 'button');
|
||||
else card.removeAttribute('role');
|
||||
card.setAttribute('aria-label', `${name} ${position}${actionable ? ' 직원 정보 보기' : ''}`.trim());
|
||||
if (actionable && !customCallback) card.setAttribute('aria-haspopup', 'dialog');
|
||||
else card.removeAttribute('aria-haspopup');
|
||||
}
|
||||
}
|
||||
|
||||
interface Instance {
|
||||
readonly handle: OrgChartHandle;
|
||||
readonly generation: number;
|
||||
source: OrgChartDataSource;
|
||||
autoLayout: OrgChartAutoLayoutOptions | undefined;
|
||||
interactions: OrgChartInteractionOptions | undefined;
|
||||
dragDropEnabled: boolean;
|
||||
readonly employeeClick: NonNullable<OrgChartOptions['onEmployeeClick']>;
|
||||
employeeCallbackEnabled: boolean;
|
||||
popupMode: 'core' | 'hm-dialog' | 'none';
|
||||
operation: number;
|
||||
}
|
||||
|
||||
@@ -19,6 +44,9 @@ export function OrgChart({
|
||||
stylesheetUrls,
|
||||
displayMode,
|
||||
layoutMode,
|
||||
autoLayout,
|
||||
interactions,
|
||||
dragDropEnabled = false,
|
||||
companyId,
|
||||
selectedCompanyIds,
|
||||
companyTopNodes,
|
||||
@@ -26,6 +54,8 @@ export function OrgChart({
|
||||
zoom,
|
||||
organizationLinks,
|
||||
onEmployeeClick,
|
||||
onMove,
|
||||
employeePopup = 'core',
|
||||
className,
|
||||
style,
|
||||
onStateChange,
|
||||
@@ -34,17 +64,22 @@ export function OrgChart({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const instanceRef = useRef<Instance | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
const committedRef = useRef({ dataSource, onStateChange, onLifecycleError, onEmployeeClick });
|
||||
const [employeeSelection, setEmployeeSelection] = useState<EmployeeDialogSelection | null>(null);
|
||||
const committedRef = useRef({ dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup });
|
||||
|
||||
// Publish committed values before Core effects run, without letting callback
|
||||
// identity changes or abandoned concurrent renders alter Core ownership.
|
||||
useLayoutEffect(() => {
|
||||
committedRef.current = { dataSource, onStateChange, onLifecycleError, onEmployeeClick };
|
||||
}, [dataSource, onStateChange, onLifecycleError, onEmployeeClick]);
|
||||
committedRef.current = { dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup };
|
||||
}, [dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup]);
|
||||
|
||||
const engineStylesheetUrl = stylesheetUrls.engine;
|
||||
const coreStylesheetUrl = stylesheetUrls.core;
|
||||
const hasEmployeeCallback = typeof onEmployeeClick === 'function';
|
||||
const popupMode = layoutMode === 'auto' ? employeePopup : 'core';
|
||||
const adapterOwnsPopup = popupMode !== 'core';
|
||||
const hasAdapterCallback = hasEmployeeCallback || popupMode !== 'core';
|
||||
const fixedHasEmployeeCallback = layoutMode !== 'auto' && hasEmployeeCallback;
|
||||
|
||||
// Ownership changes are synchronous with the commit: an old request cannot
|
||||
// notify between a committed unmount/source change and a passive cleanup.
|
||||
@@ -54,6 +89,7 @@ export function OrgChart({
|
||||
const generation = ++generationRef.current;
|
||||
let handle: OrgChartHandle | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let popupObserver: MutationObserver | undefined;
|
||||
|
||||
const reportError = (error: unknown) => {
|
||||
if (generationRef.current !== generation) return;
|
||||
@@ -63,23 +99,35 @@ export function OrgChart({
|
||||
|
||||
try {
|
||||
const source = committedRef.current.dataSource;
|
||||
const employeeClick: NonNullable<OrgChartOptions['onEmployeeClick']> = (employee, context) => {
|
||||
if (generationRef.current !== generation) return;
|
||||
const callback = committedRef.current.onEmployeeClick;
|
||||
if (callback) callback(employee, context);
|
||||
else if (committedRef.current.employeePopup === 'hm-dialog') setEmployeeSelection({ employee, context });
|
||||
};
|
||||
handle = createOrgChart({
|
||||
dataSource: source,
|
||||
aggregationPolicy,
|
||||
stylesheetUrls: { engine: engineStylesheetUrl, core: coreStylesheetUrl },
|
||||
displayMode,
|
||||
layoutMode,
|
||||
autoLayout,
|
||||
interactions,
|
||||
dragDropEnabled,
|
||||
companyId,
|
||||
selectedCompanyIds,
|
||||
companyTopNodes,
|
||||
employeeConnections,
|
||||
zoom,
|
||||
organizationLinks,
|
||||
onEmployeeClick: hasEmployeeCallback ? (employee, context) => {
|
||||
if (generationRef.current === generation) committedRef.current.onEmployeeClick?.(employee, context);
|
||||
} : undefined,
|
||||
onEmployeeClick: hasAdapterCallback ? employeeClick : undefined,
|
||||
onMove: result => {
|
||||
if (generationRef.current !== generation) return;
|
||||
if (result.moved) setEmployeeSelection(null);
|
||||
committedRef.current.onMove?.(result);
|
||||
},
|
||||
});
|
||||
const instance: Instance = { handle, source, generation, operation: 1 };
|
||||
const instance: Instance = { handle, source, autoLayout, interactions, dragDropEnabled, employeeClick, employeeCallbackEnabled: hasAdapterCallback, popupMode, generation, operation: 1 };
|
||||
instanceRef.current = instance;
|
||||
unsubscribe = handle.subscribe(state => {
|
||||
if (instanceRef.current === instance && generationRef.current === generation) {
|
||||
@@ -92,6 +140,18 @@ export function OrgChart({
|
||||
void handle.mount(container).catch(error => {
|
||||
if (instanceRef.current === instance && instance.operation === operation) reportError(error);
|
||||
});
|
||||
const shadow = container.firstElementChild?.shadowRoot;
|
||||
if (shadow && layoutMode === 'auto') {
|
||||
const annotateCards = () => {
|
||||
annotateEmployeeCards(shadow, committedRef.current.employeePopup, Boolean(committedRef.current.onEmployeeClick));
|
||||
};
|
||||
const Observer = container.ownerDocument.defaultView?.MutationObserver;
|
||||
if (Observer) {
|
||||
popupObserver = new Observer(annotateCards);
|
||||
popupObserver.observe(shadow, { childList: true, subtree: true });
|
||||
}
|
||||
annotateCards();
|
||||
}
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
}
|
||||
@@ -100,6 +160,7 @@ export function OrgChart({
|
||||
// Invalidate callbacks and promise continuations before terminal disposal.
|
||||
if (generationRef.current === generation) ++generationRef.current;
|
||||
if (instanceRef.current?.generation === generation) instanceRef.current = null;
|
||||
popupObserver?.disconnect();
|
||||
try { unsubscribe?.(); }
|
||||
catch (error) {
|
||||
try { committedRef.current.onLifecycleError?.(error); }
|
||||
@@ -113,7 +174,64 @@ export function OrgChart({
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [aggregationPolicy, engineStylesheetUrl, coreStylesheetUrl, displayMode, layoutMode, companyId, companyTopNodes, employeeConnections, zoom, organizationLinks, hasEmployeeCallback]);
|
||||
}, [aggregationPolicy, engineStylesheetUrl, coreStylesheetUrl, displayMode, layoutMode, companyId, companyTopNodes, employeeConnections, zoom, organizationLinks, fixedHasEmployeeCallback]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const instance = instanceRef.current;
|
||||
if (!instance || layoutMode !== 'auto' || (instance.employeeCallbackEnabled === hasAdapterCallback && instance.popupMode === popupMode)) return;
|
||||
try {
|
||||
instance.handle.setEmployeeClickHandler(hasAdapterCallback ? instance.employeeClick : undefined);
|
||||
instance.employeeCallbackEnabled = hasAdapterCallback;
|
||||
instance.popupMode = popupMode;
|
||||
} catch (error) {
|
||||
try { committedRef.current.onLifecycleError?.(error); }
|
||||
catch { /* Match Core's callback ownership isolation. */ }
|
||||
}
|
||||
}, [hasAdapterCallback, popupMode, layoutMode]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Close a host-owned popup before committing a changed source/company view;
|
||||
// no asynchronous fixture request can leave a stale employee displayed.
|
||||
setEmployeeSelection(null);
|
||||
const shadow = containerRef.current?.firstElementChild?.shadowRoot;
|
||||
if (shadow && layoutMode === 'auto') {
|
||||
annotateEmployeeCards(shadow, popupMode, hasEmployeeCallback);
|
||||
}
|
||||
}, [dataSource, selectedCompanyIds, popupMode, hasEmployeeCallback, layoutMode, adapterOwnsPopup]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const instance = instanceRef.current;
|
||||
if (!instance || layoutMode !== 'auto' || instance.autoLayout === autoLayout) return;
|
||||
instance.autoLayout = autoLayout;
|
||||
void instance.handle.setAutoLayout(autoLayout ?? { type: 'tb', hybridStartDepth: 3, initialVisibleDepth: 'all', hybridOrganizationIds: [] }).catch(error => {
|
||||
if (instanceRef.current !== instance) return;
|
||||
try { committedRef.current.onLifecycleError?.(error); }
|
||||
catch { /* Keep layout setter failures in the lifecycle error contract. */ }
|
||||
});
|
||||
}, [autoLayout, layoutMode]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const instance = instanceRef.current;
|
||||
if (!instance || layoutMode !== 'auto' || instance.dragDropEnabled === dragDropEnabled) return;
|
||||
try {
|
||||
instance.handle.setDragDropEnabled(dragDropEnabled);
|
||||
instance.dragDropEnabled = dragDropEnabled;
|
||||
} catch (error) {
|
||||
try { committedRef.current.onLifecycleError?.(error); }
|
||||
catch { /* Keep option changes in the lifecycle error contract. */ }
|
||||
}
|
||||
}, [dragDropEnabled, layoutMode]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const instance = instanceRef.current;
|
||||
if (!instance || layoutMode !== 'auto' || instance.interactions === interactions) return;
|
||||
instance.interactions = interactions;
|
||||
void instance.handle.setInteractions({ collapse: true, zoom: true, pan: true, ...interactions }).catch(error => {
|
||||
if (instanceRef.current !== instance) return;
|
||||
try { committedRef.current.onLifecycleError?.(error); }
|
||||
catch { /* Interaction controls use the existing lifecycle error contract. */ }
|
||||
});
|
||||
}, [interactions, layoutMode]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const instance = instanceRef.current;
|
||||
@@ -139,5 +257,10 @@ export function OrgChart({
|
||||
});
|
||||
}, [dataSource]);
|
||||
|
||||
return <div ref={containerRef} className={className} style={style} />;
|
||||
return <>
|
||||
<div ref={containerRef} className={className} style={style} />
|
||||
{popupMode === 'hm-dialog' && !hasEmployeeCallback ? <Suspense fallback={null}>
|
||||
<HMEmployeeDialog selection={employeeSelection} onClose={() => setEmployeeSelection(null)} />
|
||||
</Suspense> : null}
|
||||
</>;
|
||||
}
|
||||
Reference in new issue
Block a user