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,6 +1,6 @@
|
||||
# @hm-design/org-chart-react v0.1
|
||||
|
||||
React 19 lifecycle adapter for `@hm-design/org-chart@0.1.0`. The wrapper renders one empty host container and delegates all organization DOM, engine work, validation, summaries, interactions and Shadow DOM styles to the existing Vanilla Core. It has no navigation, page layout, fetch client or separate node design.
|
||||
React 19 lifecycle adapter for `@hm-design/org-chart@0.1.0`. The wrapper delegates organization DOM, engine work, validation, summaries, interactions and Shadow DOM styles to the existing Vanilla Core. Auto can optionally show employee information with the public HM Dialog in a separate React Portal. It has no navigation, page layout, fetch client or separate node design.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -33,7 +33,7 @@ export function OrganizationContent({ data }: { data: OrgChartDataset }) {
|
||||
}
|
||||
```
|
||||
|
||||
The `?url` imports above are Vite asset imports, not a universal browser feature. Other hosts resolve the two Core public CSS entries to actual deployment URLs. Importing CSS only into the document does not style the Core's Shadow DOM. Engine CSS is linked first, then standalone Core CSS. P1 owns its colors, typography and geometry without depending on HM Theme. Set public `--hm-orgchart-*` properties on the wrapper container to override only this instance. No HM common variables are redefined. The wrapper does not depend on `@hm-design/ui`; P3 adds lookup Props without replacing the Core renderer. For independent Tree/Summary integration, use the [Core factories](https://gitea.hmac.kr/b25027/HMDesign/src/commit/d2b76e766466e1f156f6a0f8f63710e023af900e/docs/org-chart/standalone-components-p1.md) in a ref/effect without copying their UI.
|
||||
The `?url` imports above are Vite asset imports, not a universal browser feature. Other hosts resolve the two Core public CSS entries to actual deployment URLs. Importing CSS only into the document does not style the Core's Shadow DOM. Engine CSS is linked first, then standalone Core CSS. P1 owns its colors, typography and geometry without depending on HM Theme. Set public `--hm-orgchart-*` properties on the wrapper container to override only this instance. No HM common variables are redefined. Default Core rendering does not load `@hm-design/ui`; opting into `employeePopup="hm-dialog"` loads the optional peer and requires the consumer's HM Theme CSS. Bundlers may resolve that optional dynamic chunk during a build even for a default consumer. For independent Tree/Summary integration, use the [Core factories](https://gitea.hmac.kr/b25027/HMDesign/src/commit/d2b76e766466e1f156f6a0f8f63710e023af900e/docs/org-chart/standalone-components-p1.md) in a ref/effect without copying their UI.
|
||||
|
||||
## Public API
|
||||
|
||||
@@ -52,9 +52,13 @@ Keyboard focus reveals offscreen controls at the current Zoom; group-title Hover
|
||||
feedback and drag/keyboard activation are implemented in the shared Core.
|
||||
- Optional `companyId`: legacy Auto fallback company preset (default `hanmac`). Explicit/inherited organization company identity takes priority; Fixed ignores company colors. Changing it recreates the handle. See [Core README](https://gitea.hmac.kr/b25027/HMDesign/src/commit/d2b76e766466e1f156f6a0f8f63710e023af900e/packages/org-chart/README.md).
|
||||
- Optional `selectedCompanyIds`: undefined selects all, [] selects none. Changes call Core selection on the existing instance, preserving per-company collapse state and source counts.
|
||||
- Optional `autoLayout`: `{ type: 'tb' | 'lr' | 'hybrid', hybridStartDepth: 2 | 3 | 4, initialVisibleDepth: 1 | 2 | 3 | 'all', hybridOrganizationIds?: readonly string[] }` (each field optional). Changes use Core `setAutoLayout` on the existing instance; TB/LR/Hybrid changes retain valid collapse state. Per-group hybrid IDs apply only to TB; clear them when selecting LR/global Hybrid. Omission follows Core defaults (TB, Hybrid depth 3, all visible).
|
||||
- Optional `interactions`: `{ collapse?: boolean, zoom?: boolean, pan?: boolean }`, all enabled by default. Auto independently enables/disables group toggles, Zoom controls/wheel Zoom and drag Pan via Core `setInteractions` on the same instance. Omitted fields are enabled, and removing the prop restores all three defaults. Existing collapse and viewport state remain; TB/LR/Hybrid has no interaction prerequisite. Fixed ignores this option and keeps its existing behavior.
|
||||
- Optional `employeePopup`: `'core'` (default), `'hm-dialog'`, or `'none'`. Applies only to Auto; Fixed retains its existing Core popup. HM Dialog requires the optional `@hm-design/ui@0.1.0` peer and the consumer's real HM Theme/Tailwind CSS entry. It is loaded lazily so default/Core-only consumers do not load HM UI. An explicit `onEmployeeClick` always takes precedence and suppresses either default popup. HM popup state closes on source/company changes and resolves the employee's replacement card in the original ShadowRoot when returning focus after a layout render. Auto uses Core `setEmployeeClickHandler` to change popup ownership on the same instance, retaining company selection, layout, collapse and viewport state.
|
||||
- Optional `companyTopNodes`, `employeeConnections`, `zoom`: immutable Core configuration maps/range. Identity changes recreate the handle; memoize these objects. Names, positions and profile overrides affect only Auto presentation, not callback data.
|
||||
- Optional `organizationLinks`: existing organization-ID-to-URL map, applied only to original 1Depth titles. Missing/blank values retain plain titles. Core validates HTTP(S)/relative URLs and rejects invalid URLs, other schemes and credentials. Treat the map as immutable and memoize it; a new map recreates the handle.
|
||||
- Optional `onEmployeeClick(employee, context)`: host-owned information UI, replacing the Core's built-in popup. `context` contains the existing `organization`, `affiliation`, focusable `trigger` card and `originalEvent`. It works in both modes and receives untouched Employee data.
|
||||
- Optional `onMove(result: OrgChartMoveResult)`: latest committed callback receives Auto employee/group move outcomes (`moved` distinguishes success from rejection). Successful moves close an open HM employee Dialog, so its employee context cannot become stale. Changing this callback does not recreate or reload Core. Movement changes the Core's in-memory snapshot; no fixture or server is written.
|
||||
- Optional `aggregationPolicy`: the Core's `ApprovedAggregationPolicy`.
|
||||
- Optional `className` and `style`: applied only to React's outer container.
|
||||
- Optional `onStateChange(state: OrgChartState)`: latest committed callback receives actual Core state changes.
|
||||
@@ -62,7 +66,7 @@ feedback and drag/keyboard activation are implemented in the shared Core.
|
||||
|
||||
The container is owned by React; its dedicated children are owned by Core. There are no React children, imperative handle, title, employee or engine manipulation Props. Titles, descriptions, presentation groups, organization order and metadata use the common data contract. An omitted lifecycle error callback leaves unexpected operation errors handled but does not produce a second error UI or console log. Consumer state callback errors follow the Core's isolation behavior; lifecycle error callback exceptions are also contained.
|
||||
|
||||
Without `onEmployeeClick`, all employee cards open the same Core read-only information dialog on click, Enter or Space. It uses only existing employee/organization/affiliation fields and omits missing values. readOnly names match the displayed `홍 길 동` card while source data remains unchanged. The native modal sits outside the scaled Stage and supports Close, ESC, focus trapping and return to the card. Group-link events remain separate; a visible name tooltip closes before popup activation. A custom callback owns its popup's accessibility and cleanup.
|
||||
Without `onEmployeeClick`, employee cards follow `employeePopup` in Auto: Core read-only information dialog (default), public HM Dialog, or no popup. Fixed always retains the Core popup. Both dialogs use only existing employee/organization/affiliation fields and omit missing values. Core readOnly names match the displayed card while source data remains unchanged; HM Dialog shows source employee data. The native modal sits outside the scaled Stage and HM Dialog uses a React Portal; both support Close, ESC, focus trapping and return to the card. Group-link events remain separate; a visible name tooltip closes before popup activation. A custom callback owns its popup's accessibility and cleanup.
|
||||
|
||||
```tsx
|
||||
<OrgChart
|
||||
@@ -78,11 +82,20 @@ Both map and popup function above are supplied by the host; no real destination
|
||||
|
||||
## Lifecycle and updates
|
||||
|
||||
P9.4 adds `dragDropEnabled?: boolean` (default `false`) for Auto. Pass `true` to
|
||||
enable employee/group pointer, keyboard and public Core moves. Changing this prop
|
||||
calls the shared Core `setDragDropEnabled` synchronously on the existing handle;
|
||||
it neither remounts nor reloads the source. Switching OFF cancels active movement,
|
||||
clears highlights and retains completed moves, popup, collapse and viewport state.
|
||||
Pan/Zoom/Fit remain independent. Fixed ignores the prop.
|
||||
|
||||
Mount creates one new Core handle, subscribes to its state and calls `mount(container)`. Mount already loads the source; the wrapper does not perform an additional initial reload. The wrapper does not synthesize `idle`, `destroyed` or `empty` callbacks. Current Core emits `loading`, then `ready`/`error`; employees `[]` is ready, organizations `[]` is error. An `empty` type member does not establish a supported Empty State.
|
||||
|
||||
A new `dataSource` object triggers the existing handle's `reload(nextSource)`. This starts Core's latest revision and aborts or ignores older responses. Updating fields inside the same source object is not a reload signal. `JsonOrgChartDataSource` captures inline input at its construction; create a new source for a new immutable snapshot. Memoize sources rather than constructing one on every render.
|
||||
|
||||
Changing callback identity updates the latest committed callback without reload or recreation. Adding/removing `onEmployeeClick` switches built-in/custom popup ownership and recreates the handle. Abandoned concurrent renders do not update callbacks. Policy or `organizationLinks` identity changes, displayMode changes, or either stylesheet URL string value changes dispose the previous handle and create a new one because the Core offers no configuration setter. Replacing the stylesheet URL object with the same two strings does not recreate. Treat policy and link-map objects as immutable and memoize them; changing an object internally is not a configuration update signal.
|
||||
Changing callback identity updates the latest committed callback without reload or recreation. In Auto, adding/removing `onEmployeeClick` or changing `employeePopup` updates Core's employee handler without replacing the handle. Fixed retains its original ownership policy: adding/removing `onEmployeeClick` recreates the handle. Abandoned concurrent renders do not update callbacks. Policy or `organizationLinks` identity changes, displayMode changes, or either stylesheet URL string value changes dispose the previous handle and create a new one because these settings have no Core setter. Replacing the stylesheet URL object with the same two strings does not recreate. Treat policy and link-map objects as immutable and memoize them; changing an object internally is not a configuration update signal.
|
||||
|
||||
Auto separates blank-space Pan from employee-card/group-title movement. Click continues to open employee information or toggle a group; employees and groups may move within or across companies. Employee moves update `organizationId`; group moves update `parentId` and retain the subtree and IDs. Within-company moves preserve existing affiliations, including dispatched employees. Cross-company employee moves additionally update `affiliationId` to the target company; cross-company group moves update the moved group's and every descendant's `companyId` and their employees' `affiliationId`. The target company controls colors and counts; leaders and company roots remain protected. Focus a card/title and press Alt+M to begin keyboard movement, arrow keys to choose a target, Enter to confirm, or ESC to cancel. Employee cards retain a keyboard focus entry point when `employeePopup="none"`. Fixed has no movement editing.
|
||||
|
||||
Cleanup invalidates the wrapper generation, unsubscribes, calls Core's terminal `destroy()` and clears its handle reference. React Strict Mode's setup → cleanup → setup uses a fresh handle. Old generations cannot deliver state/lifecycle callbacks to the new instance; superseded operation rejection handlers are also ignored. Cleanup runs before replacement, leaving at most one active Core per wrapper container. The wrapper does not repeat engine disposal or DOM removal itself.
|
||||
|
||||
@@ -92,7 +105,7 @@ Committed callback publication, Core ownership and source updates use layout eff
|
||||
|
||||
## Package boundary and verification limits
|
||||
|
||||
The build emits ESM `dist/index.js` plus TypeScript declarations, preserves the `"use client"` boundary and leaves React (including `react/jsx-runtime`) and `@hm-design/org-chart` external. React is a `^19.0.0` peer, matching the repository's existing React package convention; Core `0.1.0` is a runtime dependency. React DOM is supplied by the consuming app and is not imported by this package. Core must be built before wrapper typecheck/build in this workspace because dependency resolution uses its public declarations.
|
||||
The build emits ESM `dist/index.js`, a lazy HM Dialog chunk, and TypeScript declarations. It preserves the `"use client"` boundary and leaves React (including `react/jsx-runtime`), `@hm-design/org-chart`, and optional `@hm-design/ui` external. React is a `^19.0.0` peer, matching the repository's existing React package convention; Core `0.1.0` is a runtime dependency. React DOM is supplied by the consuming app and is not imported by this package. Core must be built before wrapper typecheck/build in this workspace because dependency resolution uses its public declarations.
|
||||
|
||||
From the repository root:
|
||||
|
||||
|
||||
@@ -20,5 +20,6 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": { "@hm-design/org-chart": "0.1.0" },
|
||||
"peerDependencies": { "react": "^19.0.0" }
|
||||
"peerDependencies": { "react": "^19.0.0", "@hm-design/ui": "0.1.0" },
|
||||
"peerDependenciesMeta": { "@hm-design/ui": { "optional": true } }
|
||||
}
|
||||
@@ -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