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
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import {
|
||||
Avatar, AvatarFallback, AvatarImage, Button, Dialog, DialogBody, DialogClose,
|
||||
DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@hm-design/ui';
|
||||
import type { Employee, OrgChartEmployeeClickContext } from '@hm-design/org-chart';
|
||||
|
||||
export interface EmployeeDialogSelection {
|
||||
employee: Employee;
|
||||
context: OrgChartEmployeeClickContext;
|
||||
}
|
||||
|
||||
const statusNames = { active: '재직', dispatched: '파견', onLeave: '휴직' } as const;
|
||||
|
||||
/** Lazy React-only adapter. The Core and Vanilla remain independent of HM UI. */
|
||||
export default function HMEmployeeDialog({ selection, onClose }: {
|
||||
selection: EmployeeDialogSelection | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const returnTarget = useRef<EmployeeDialogSelection | null>(null);
|
||||
const returnRoot = useRef<ShadowRoot | null>(null);
|
||||
const returnContainer = useRef<HTMLElement | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (!selection) return;
|
||||
returnTarget.current = selection;
|
||||
const root = selection.context.trigger.getRootNode();
|
||||
const Shadow = selection.context.trigger.ownerDocument.defaultView?.ShadowRoot;
|
||||
returnRoot.current = Shadow && root instanceof Shadow ? root : null;
|
||||
returnContainer.current = returnRoot.current?.host.parentElement ?? null;
|
||||
}, [selection]);
|
||||
|
||||
function restoreFocus(event: Event) {
|
||||
event.preventDefault();
|
||||
const previous = returnTarget.current;
|
||||
returnTarget.current = null;
|
||||
if (!previous) return;
|
||||
const { trigger } = previous.context;
|
||||
// A mode/selection render can replace the card while the Portal is open.
|
||||
// Retain its ShadowRoot before replacement and resolve the same employee.
|
||||
const shadow = returnRoot.current;
|
||||
const currentRoot = shadow?.host.isConnected ? shadow : returnContainer.current?.firstElementChild?.shadowRoot;
|
||||
const replacement = [...currentRoot?.querySelectorAll<HTMLElement>('.hm-orgchart-employee') ?? []]
|
||||
.find(card => card.dataset.employeeId === previous.employee.id);
|
||||
const target = trigger.isConnected ? trigger : replacement
|
||||
?? currentRoot?.querySelector<HTMLElement>('.hm-orgchart-shell');
|
||||
target?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
const displayed = selection ?? returnTarget.current;
|
||||
const employee = displayed?.employee;
|
||||
const context = displayed?.context;
|
||||
const image = employee?.profileImage
|
||||
?? context?.trigger.querySelector<HTMLImageElement>('.hm-orgchart-avatar img')?.src;
|
||||
const fields: [string, string | null | undefined][] = employee ? [
|
||||
['이름', employee.name], ['직위', employee.position],
|
||||
['소속 회사', context?.affiliation?.name], ['소속 조직', context?.organization?.name],
|
||||
['직책', employee.jobTitle], ['근무 상태', statusNames[employee.workStatus]],
|
||||
] : [];
|
||||
|
||||
return <Dialog open={selection !== null} onOpenChange={open => { if (!open) onClose(); }}>
|
||||
<DialogContent onCloseAutoFocus={restoreFocus} data-component="HMOrgChartEmployeeDialog" data-employee-id={employee?.id}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>직원 정보</DialogTitle>
|
||||
<DialogDescription>조직도에서 선택한 직원의 정보를 확인합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Avatar size="lg">
|
||||
{image ? <AvatarImage src={image} alt="" referrerPolicy="no-referrer" /> : null}
|
||||
<AvatarFallback>{employee?.name.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<dl style={{ display: 'grid', gap: '0.75rem', margin: '1rem 0 0' }}>{fields.filter(([, value]) => Boolean(value)).map(([label, value]) => <div key={label} style={{ display: 'grid', gridTemplateColumns: '5rem minmax(0, 1fr)', gap: '0.75rem' }}>
|
||||
<dt style={{ fontWeight: 600 }}>{label}</dt><dd style={{ margin: 0, minWidth: 0, overflowWrap: 'anywhere' }}>{value}</dd>
|
||||
</div>)}</dl>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild><Button variant="cancel" appearance="solid">닫기</Button></DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -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}
|
||||
</>;
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { OrgChartOptions, OrgChartState } from '@hm-design/org-chart';
|
||||
|
||||
/** The Core owns data, rendering, organization links and employee information
|
||||
* interactions; React owns its dedicated container. */
|
||||
/** The Core owns data, organization rendering and interactions; React owns its
|
||||
* dedicated container and the optional Portal-based HM employee information UI. */
|
||||
export interface OrgChartProps extends Omit<OrgChartOptions, 'container' | 'onStateChange'> {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
/** Auto only. Opt into the public HM Dialog (requires @hm-design/ui + its
|
||||
* Theme), retain the Vanilla popup, or suppress the default information UI.
|
||||
* An explicit onEmployeeClick callback always owns the popup instead. */
|
||||
employeePopup?: 'core' | 'hm-dialog' | 'none';
|
||||
/** Latest committed callback. Controlled data/style failures arrive as error states. */
|
||||
onStateChange?: (state: OrgChartState) => void;
|
||||
/** Unexpected create/mount/reload/cleanup errors, rather than controlled error states. */
|
||||
|
||||
Reference in new issue
Block a user