feat(org-chart): add core cards and fixed-layout integration
This commit is contained in:
1 parent
d18e3c3380
commit
61c6f8d1f1
161 files changed
+13044
-1
No files matched your search
@@ -0,0 +1,81 @@
|
||||
/// <reference path="../../packages/org-chart/src/core/vendor.d.ts" />
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import { calculateSummary, normalizeOrgChartDataset, validateOrgChartDataset } from '@hm-design/org-chart';
|
||||
import engineUrl from '@hm-design/org-chart/engine.css?url';
|
||||
import coreUrl from '@hm-design/org-chart/style.css?url';
|
||||
import { createEmployeeCard, type EmployeeCardOptions } from '../../packages/org-chart/src/core/employee-card';
|
||||
import { decorateNode, engineTree, type RenderContext } from '../../packages/org-chart/src/core/render';
|
||||
import { applyOrganizationStyle } from '../../packages/org-chart/src/core/style-presets';
|
||||
import { createPositionSummary } from '../../packages/org-chart/src/core/position-summary';
|
||||
import { createAffiliationSummary } from '../../packages/org-chart/src/core/affiliation-summary';
|
||||
import { createStatusSummary } from '../../packages/org-chart/src/core/status-summary';
|
||||
import { createLogoLegend } from '../../packages/org-chart/src/core/logo-legend';
|
||||
import { createDepartmentColors, updateDepartmentColorValues } from '../../packages/org-chart/src/core/department-colors';
|
||||
import fixture from '../../examples/org-chart/data-p1.json';
|
||||
|
||||
type PreviewKind = 'Logo' | 'DepartmentColors' | 'PositionSummary' | 'AffiliationSummary' | 'StatusSummary' | 'EmployeeCard' | 'OrganizationCards';
|
||||
interface Props { kind: PreviewKind; data?: unknown; variant?: number; cardKind?: EmployeeCardOptions['kind'] }
|
||||
|
||||
/** Story host only. Every pixel/card/count is rendered by the same Core modules
|
||||
* used by Vanilla HTML and the production React Wrapper, not JSX copies. */
|
||||
export function CoreComponentPreview({ kind, data = fixture, variant = 0, cardKind = 'employee' }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const validation = validateOrgChartDataset(data);
|
||||
const host = ref.current!;
|
||||
if (!validation.valid) { host.textContent = validation.issues.map(issue => issue.code).join(', '); return; }
|
||||
const dataset = validation.data;
|
||||
const normalized = normalizeOrgChartDataset(dataset);
|
||||
const context: RenderContext = {
|
||||
data: dataset, summary: calculateSummary(dataset), orderedOrganizations: [], employeesByOrganization: normalized.employeesByOrganization,
|
||||
topLeaderIds: new Set(normalized.roots.filter(node => node.organization.type === 'root' && node.organization.leaderId).map(node => node.organization.leaderId!)),
|
||||
};
|
||||
engineTree(normalized.roots, 'component-preview', context);
|
||||
const controller = new AbortController();
|
||||
const root = document.createElement('div');
|
||||
root.className = 'hm-orgchart-component-host';
|
||||
const shadow = root.attachShadow({ mode: 'open' });
|
||||
for (const href of [engineUrl, coreUrl]) {
|
||||
const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; shadow.append(link);
|
||||
link.addEventListener('load', () => updateDepartmentColorValues(shell), { signal: controller.signal });
|
||||
}
|
||||
const shell = document.createElement('section');
|
||||
shell.className = 'hm-orgchart-shell';
|
||||
let component: HTMLElement;
|
||||
if (kind === 'Logo') component = createLogoLegend(document, context, controller.signal);
|
||||
else if (kind === 'DepartmentColors') component = createDepartmentColors(document, context, controller.signal);
|
||||
else if (kind === 'PositionSummary') component = createPositionSummary(document, context);
|
||||
else if (kind === 'AffiliationSummary') component = createAffiliationSummary(document, context);
|
||||
else if (kind === 'StatusSummary') component = createStatusSummary(document, context, controller.signal);
|
||||
else if (kind === 'OrganizationCards') {
|
||||
const organization = normalized.roots[0]?.children[variant]?.organization ?? normalized.roots[0]!.organization;
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'hm-orgchart-engine orgchart hm-orgchart-component-cards';
|
||||
component = document.createElement('div'); component.className = 'node';
|
||||
const card = document.createElement('article'); card.className = 'hm-orgchart-organization'; component.append(card);
|
||||
decorateNode(component, { id: 'preview-org', name: '', organizationIndex: context.orderedOrganizations.indexOf(organization), depth: 1, groupVariant: variant }, context, controller.signal, () => undefined);
|
||||
// Branch members stay in their actual fixture organizations; previews use
|
||||
// the shared EmployeeCard renderer without modifying primary membership.
|
||||
const roster = document.createElement('div'); roster.className = 'hm-orgchart-preview-grid';
|
||||
const descendants = new Set<string>([organization.id]);
|
||||
for (const item of context.orderedOrganizations) if (item.parentId && descendants.has(item.parentId)) descendants.add(item.id);
|
||||
for (const employee of dataset.employees.filter(item => descendants.has(item.organizationId) && item.id !== organization.leaderId).slice(0, 3)) {
|
||||
roster.append(createEmployeeCard(document, employee, { kind: 'employee', affiliationName: dataset.affiliations.find(item => item.id === employee.affiliationId)?.name }, controller.signal));
|
||||
}
|
||||
component.append(roster); wrapper.append(component); component = wrapper;
|
||||
} else {
|
||||
const organization = normalized.roots[0]?.children[variant]?.organization ?? normalized.roots[0]!.organization;
|
||||
const id = cardKind === 'topLeader' ? normalized.roots[0]!.organization.leaderId : organization.leaderId;
|
||||
const employee = cardKind !== 'employee' ? dataset.employees.find(item => item.id === id) : dataset.employees.find(item => item.id !== id && item.organizationId === organization.id) ?? dataset.employees.find(item => !item.jobTitle);
|
||||
component = createEmployeeCard(document, employee ?? dataset.employees[0]!, { kind: cardKind, affiliationName: dataset.affiliations.find(item => item.id === employee?.affiliationId)?.name }, controller.signal);
|
||||
applyOrganizationStyle(component, variant);
|
||||
}
|
||||
component.dataset.component = kind;
|
||||
shell.append(component); shadow.append(shell); host.replaceChildren(root);
|
||||
updateDepartmentColorValues(shell);
|
||||
const observer = kind === 'DepartmentColors' ? new ResizeObserver(() => updateDepartmentColorValues(shell)) : undefined;
|
||||
observer?.observe(root);
|
||||
return () => { observer?.disconnect(); controller.abort(); root.remove(); };
|
||||
}, [kind, data, variant, cardKind]);
|
||||
return <div ref={ref} className="hm-orgchart-story-chart" />;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { CoreComponentPreview } from './core-component-preview';
|
||||
import { organizationStylePresets } from '../../packages/org-chart/src/core/style-presets';
|
||||
|
||||
const meta = { title: 'OrgChart/Components/Cards', tags: ['internal'], component: CoreComponentPreview, parameters: { layout: 'padded', controls: { disable: true } } } satisfies Meta<typeof CoreComponentPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
function organization(variant: number): Story {
|
||||
const preset = organizationStylePresets[variant]!;
|
||||
return {
|
||||
name: preset.label,
|
||||
args: { kind: 'OrganizationCards', variant },
|
||||
render: () => <section className="hm-orgchart-story">
|
||||
<p>{preset.label} · 실측 헤더 컬러 {preset.accentColor} · {preset.backgroundImage}</p>
|
||||
<p>OrgChart 전용 --hm-orgchart-accent / --hm-orgchart-group-background. Workdesk {preset.version}, {preset.observedOn}, {preset.selector}. 직원 정보와 통계는 가상 데이터입니다. 카드 배경은 OrgChart 독립 기본값을 사용합니다.</p>
|
||||
<CoreComponentPreview kind="OrganizationCards" variant={variant} />
|
||||
</section>,
|
||||
};
|
||||
}
|
||||
export const BusinessManagement = organization(0);
|
||||
export const BusinessPlanning = organization(1);
|
||||
export const TalentGrowth = organization(2);
|
||||
export const IntegratedSystem = organization(3);
|
||||
export const DesignPlanning = organization(4);
|
||||
export const SolutionSales = organization(5);
|
||||
export const TopLeader: Story = { name: '최상위 리더', args: { kind: 'EmployeeCard', cardKind: 'topLeader' } };
|
||||
export const OrganizationLeader: Story = { name: '조직장', args: { kind: 'EmployeeCard', cardKind: 'leader' } };
|
||||
export const Employee: Story = { name: '일반 직원', args: { kind: 'EmployeeCard', cardKind: 'employee' } };
|
||||
@@ -0,0 +1,56 @@
|
||||
import { StrictMode, useMemo, useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Button } from '@hm-design/ui';
|
||||
import { OrgChart } from '@hm-design/org-chart-react';
|
||||
import { JsonOrgChartDataSource } from '@hm-design/org-chart';
|
||||
import engineUrl from '@hm-design/org-chart/engine.css?url';
|
||||
import coreUrl from '@hm-design/org-chart/style.css?url';
|
||||
import fixture from '../../examples/org-chart/data-p1.json';
|
||||
import './org-chart-stories.css';
|
||||
import { CoreComponentPreview } from './core-component-preview';
|
||||
|
||||
const compositionSource = new JsonOrgChartDataSource(fixture);
|
||||
const compositionStylesheets = { engine: engineUrl, core: coreUrl };
|
||||
|
||||
/** View-only Workdesk composition: the same Core/React Wrapper with
|
||||
* displayMode="readOnly". No Story notes, test controls or collapse buttons;
|
||||
* JSON update/lifecycle and collapse checks stay in the internal Content Stories. */
|
||||
function CompositionPreview() {
|
||||
return <section className="hm-orgchart-story hm-orgchart-story-full" aria-label="Full Composition">
|
||||
<OrgChart dataSource={compositionSource} stylesheetUrls={compositionStylesheets} displayMode="readOnly" className="hm-orgchart-story-chart" />
|
||||
</section>;
|
||||
}
|
||||
function DepartmentTypographyPreview() {
|
||||
const [longNames, setLongNames] = useState(false);
|
||||
const data = useMemo(() => {
|
||||
const data = structuredClone(fixture);
|
||||
if (longNames) for (const id of ['mock-leader-00', 'mock-leader-01', 'mock-employee-01']) {
|
||||
data.employees.find(item => item.id === id)!.name = '아주긴가상이름표시검증사례';
|
||||
}
|
||||
return data;
|
||||
}, [longNames]);
|
||||
return <section className="hm-orgchart-story">
|
||||
<p className="hm-orgchart-story-note">기본 이름은 2–3음절의 가상 이름입니다. 긴 이름 검증에서는 폰트·사진·카드 크기를 유지하며 말줄임된 이름에 Hover 또는 Tab Focus로 전체 이름을 확인하고 Escape로 닫을 수 있습니다.</p>
|
||||
<div className="hm-orgchart-story-controls"><Button appearance="outline" aria-pressed={longNames} onClick={() => setLongNames(value => !value)}>긴 이름 검증 {longNames ? '종료' : '시작'}</Button></div>
|
||||
<CoreComponentPreview kind="DepartmentColors" data={data} />
|
||||
</section>;
|
||||
}
|
||||
const meta = { title: 'OrgChart', component: CompositionPreview, parameters: { layout: 'padded', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof CompositionPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const LogoPersonnelSummary: Story = {
|
||||
name: 'Logo · Personnel Summary',
|
||||
render: () => <section className="hm-orgchart-story" aria-label="Logo · Personnel Summary">
|
||||
<CoreComponentPreview kind="Logo" />
|
||||
<CoreComponentPreview kind="PositionSummary" />
|
||||
<CoreComponentPreview kind="AffiliationSummary" />
|
||||
</section>,
|
||||
};
|
||||
export const DepartmentColors: Story = { name: 'Department Colors', render: () => <DepartmentTypographyPreview /> };
|
||||
export const FullComposition: Story = { name: 'Full Composition' };
|
||||
|
||||
// Preserve existing standalone Story IDs for verification and direct links.
|
||||
// The default manager excludes "internal"; user-facing navigation has three pages.
|
||||
export const Logo: Story = { name: 'Logo', tags: ['internal'], render: () => <CoreComponentPreview kind="Logo" /> };
|
||||
export const PositionSummary: Story = { name: 'Position Summary', tags: ['internal'], render: () => <><p className="hm-orgchart-story-note">가상 직원 데이터 자동 집계 · 실제 운영 인원수가 아닙니다.</p><CoreComponentPreview kind="PositionSummary" /></> };
|
||||
export const AffiliationSummary: Story = { name: 'Affiliation Summary', tags: ['internal'], render: () => <><p className="hm-orgchart-story-note">가상 직원 데이터 자동 집계 · 실제 운영 인원수가 아닙니다.</p><CoreComponentPreview kind="AffiliationSummary" /></> };
|
||||
@@ -0,0 +1,61 @@
|
||||
/* Story host layout only. Core nodes/styles remain inside their Shadow DOM. */
|
||||
.hm-orgchart-story {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 1880px;
|
||||
}
|
||||
|
||||
/* Full Composition scales itself to the available width; no host cap. */
|
||||
.hm-orgchart-story-full { max-width: none; }
|
||||
body:has(.hm-orgchart-story-full) { background: #F5F8FB; }
|
||||
.hm-orgchart-story-full { background: #F5F8FB; }
|
||||
|
||||
.hm-orgchart-story-note {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hm-orgchart-story-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hm-orgchart-story-state {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hm-orgchart-story-chart,
|
||||
.hm-orgchart-story-responsive,
|
||||
.hm-orgchart-story-instance {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.hm-orgchart-story-chart {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hm-orgchart-story-instances {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hm-orgchart-story-instance {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.hm-orgchart-story-instances {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { CoreComponentPreview } from "./core-component-preview"
|
||||
|
||||
// The shared P1 fixture uses real organization/company names, virtual employees,
|
||||
// and automatic Raw Count. Acquired logo mapping stays in the shared Core/asset
|
||||
// layer; Stories do not duplicate summary UI or add employee data fields.
|
||||
const meta = {
|
||||
title: "OrgChart/Components/Summaries",
|
||||
tags: ["internal"],
|
||||
component: CoreComponentPreview,
|
||||
args: { kind: "PositionSummary" },
|
||||
parameters: { layout: "padded", controls: { disable: true } },
|
||||
} satisfies Meta<typeof CoreComponentPreview>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const PositionSummary: Story = {
|
||||
render: () => <CoreComponentPreview kind="PositionSummary" />,
|
||||
}
|
||||
|
||||
export const AffiliationSummary: Story = {
|
||||
args: { kind: "AffiliationSummary" },
|
||||
render: () => <CoreComponentPreview kind="AffiliationSummary" />,
|
||||
}
|
||||
|
||||
export const StatusSummary: Story = {
|
||||
args: { kind: "StatusSummary" },
|
||||
render: () => <CoreComponentPreview kind="StatusSummary" />,
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { StrictMode, useCallback, useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button } from "@hm-design/ui"
|
||||
import { OrgChart } from "@hm-design/org-chart-react"
|
||||
import { JsonOrgChartDataSource, type OrgChartDataSource, type OrgChartState } from "@hm-design/org-chart"
|
||||
import engineCssUrl from "@hm-design/org-chart/engine.css?url"
|
||||
import coreCssUrl from "@hm-design/org-chart/style.css?url"
|
||||
import dataV1 from "../../examples/org-chart/data-v1.json"
|
||||
import dataV2 from "../../examples/org-chart/data-v2.json"
|
||||
import "./org-chart-stories.css"
|
||||
|
||||
// These are the same source files consumed by the Vanilla examples. The Core
|
||||
// validates the unknown JSON, rather than a Story casting it to a trusted type.
|
||||
const sourceV1 = new JsonOrgChartDataSource(dataV1)
|
||||
const sourceV2 = new JsonOrgChartDataSource(dataV2)
|
||||
const stylesheetUrls = { engine: engineCssUrl, core: coreCssUrl }
|
||||
|
||||
const hierarchyData = structuredClone(dataV1)
|
||||
hierarchyData.presentation.groups.showEmployees = false
|
||||
hierarchyData.presentation.groups.showSummary = false
|
||||
const hierarchySource = new JsonOrgChartDataSource(hierarchyData)
|
||||
|
||||
const employeeData = structuredClone(dataV1)
|
||||
employeeData.presentation.groups.showSummary = false
|
||||
employeeData.organizations[1].name = "가상 기획팀 · 긴 조직명을 가진 콘텐츠 표시 확인"
|
||||
employeeData.employees[2].name = "가상직원 긴 이름 표시 확인"
|
||||
employeeData.employees[2].position = "가상 장기 프로젝트 기획 및 운영 담당 선임"
|
||||
employeeData.employees[2].jobTitle = "가상 콘텐츠 및 협업 담당"
|
||||
const employeeSource = new JsonOrgChartDataSource(employeeData)
|
||||
|
||||
const noEmployeeData = structuredClone(dataV1)
|
||||
noEmployeeData.employees = []
|
||||
for (const organization of noEmployeeData.organizations) organization.leaderId = null
|
||||
noEmployeeData.metadata.dataVersion = "story-zero-employees"
|
||||
const noEmployeeSource = new JsonOrgChartDataSource(noEmployeeData)
|
||||
|
||||
const noOrganizationData = structuredClone(noEmployeeData)
|
||||
noOrganizationData.organizations = []
|
||||
noOrganizationData.relations = []
|
||||
noOrganizationData.presentation.organizationOrder = []
|
||||
noOrganizationData.metadata.dataVersion = "story-zero-organizations"
|
||||
const noOrganizationSource = new JsonOrgChartDataSource(noOrganizationData)
|
||||
|
||||
/** Test transport only: chart DOM, validation and errors remain owned by Core. */
|
||||
function delayedSource(payload: unknown, delayMs: number, fail = false, ignoreAbort = false): OrgChartDataSource {
|
||||
return {
|
||||
load({ signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = () => {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener("abort", abort)
|
||||
reject(new DOMException("Story source cancelled", "AbortError"))
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", abort)
|
||||
if (fail) reject(new Error("Fictitious Story source failure"))
|
||||
else resolve(structuredClone(payload))
|
||||
}, delayMs)
|
||||
if (!ignoreAbort) {
|
||||
if (signal?.aborted) abort()
|
||||
else signal?.addEventListener("abort", abort, { once: true })
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function Content({ note, children }: { note: string; children: ReactNode }) {
|
||||
return <section className="hm-orgchart-story"><p className="hm-orgchart-story-note text-sm text-muted-foreground">{note}</p>{children}</section>
|
||||
}
|
||||
|
||||
function StateOutput({ state, updates, label = "현재 상태" }: { state: OrgChartState; updates: number; label?: string }) {
|
||||
return (
|
||||
<output className="hm-orgchart-story-state text-sm" aria-live="polite" aria-label={label}>
|
||||
{label}: {state.status}
|
||||
{state.status === "ready" && ` · ${state.dataVersion} · 원본 직원 ${state.summary.raw.total}명`}
|
||||
{state.status === "error" && ` · ${state.issues.map(issue => issue.code).join(", ")}`}
|
||||
{` · 상태 알림 ${updates}회`}
|
||||
</output>
|
||||
)
|
||||
}
|
||||
|
||||
function useChartState() {
|
||||
const [state, setState] = useState<OrgChartState>({ status: "idle" })
|
||||
const [updates, setUpdates] = useState(0)
|
||||
const onStateChange = useCallback((next: OrgChartState) => {
|
||||
setState(next)
|
||||
setUpdates(count => count + 1)
|
||||
}, [])
|
||||
return { state, updates, onStateChange }
|
||||
}
|
||||
|
||||
function DataUpdateContent() {
|
||||
const [source, setSource] = useState<OrgChartDataSource>(sourceV1)
|
||||
const [mounted, setMounted] = useState(true)
|
||||
const status = useChartState()
|
||||
return (
|
||||
<Content note="HTML 예제와 같은 JSON v1(4명) / v2(5명)를 사용합니다. Source 객체 교체로 Core reload를 실행하며 모든 Story는 React Strict Mode에서 렌더링됩니다.">
|
||||
<div className="hm-orgchart-story-controls">
|
||||
<Button appearance="outline" onClick={() => setSource(sourceV1)}>JSON v1</Button>
|
||||
<Button appearance="outline" onClick={() => setSource(sourceV2)}>JSON v2</Button>
|
||||
<Button variant="cancel" appearance="outline" onClick={() => setMounted(value => !value)}>{mounted ? "Unmount" : "Mount"}</Button>
|
||||
</div>
|
||||
<StateOutput {...status} />
|
||||
{mounted && <OrgChart dataSource={source} stylesheetUrls={stylesheetUrls} onStateChange={status.onStateChange} className="hm-orgchart-story-chart" />}
|
||||
</Content>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingContent() {
|
||||
const [source, setSource] = useState<OrgChartDataSource>(() => delayedSource(dataV1, 1800))
|
||||
const status = useChartState()
|
||||
return (
|
||||
<Content note="직원 0명은 ready, 조직 0개는 error입니다. 실제 empty 상태 UI는 현재 Core에서 미지원(TBD)이며 지원한 것으로 표시하지 않습니다. 느린 v1 요청 중 Ready v2를 선택하면 늦은 v1 응답이 무시되어야 합니다.">
|
||||
<div className="hm-orgchart-story-controls">
|
||||
<Button appearance="outline" onClick={() => setSource(delayedSource(dataV1, 1800))}>Loading v1</Button>
|
||||
<Button appearance="outline" onClick={() => setSource(delayedSource(dataV1, 4000, false, true))}>느린 v1 (취소 무시)</Button>
|
||||
<Button variant="destructive" appearance="outline" onClick={() => setSource(delayedSource(dataV1, 350, true))}>Source Error</Button>
|
||||
<Button appearance="outline" onClick={() => setSource(sourceV2)}>Ready v2 / 복구</Button>
|
||||
<Button appearance="outline" onClick={() => setSource(noEmployeeSource)}>직원 0명 (Ready)</Button>
|
||||
<Button variant="destructive" appearance="outline" onClick={() => setSource(noOrganizationSource)}>조직 0개 (Error)</Button>
|
||||
</div>
|
||||
<StateOutput {...status} />
|
||||
<OrgChart dataSource={source} stylesheetUrls={stylesheetUrls} onStateChange={status.onStateChange} className="hm-orgchart-story-chart" />
|
||||
</Content>
|
||||
)
|
||||
}
|
||||
|
||||
function ResponsiveContent() {
|
||||
const [width, setWidth] = useState(390)
|
||||
return (
|
||||
<Content note="고급 모바일 재구성 없이 원래 노드 크기와 Core 내부 가로 스크롤을 유지합니다. 아래 폭은 현재 Storybook viewport보다 넓어지지 않습니다. 실제 390 / 768 / 1280 / 1440px 검증은 각 viewport에서 수행합니다. Zoom/Pan은 미지원입니다.">
|
||||
<div className="hm-orgchart-story-controls">
|
||||
{[390, 768, 1280, 1440].map(value => <Button key={value} appearance="outline" aria-pressed={width === value} onClick={() => setWidth(value)}>{value}px</Button>)}
|
||||
</div>
|
||||
<div className="hm-orgchart-story-responsive" style={{ width }}>
|
||||
<OrgChart dataSource={sourceV1} stylesheetUrls={stylesheetUrls} className="hm-orgchart-story-chart" />
|
||||
</div>
|
||||
</Content>
|
||||
)
|
||||
}
|
||||
|
||||
function MultipleContent() {
|
||||
const [sourceA, setSourceA] = useState<OrgChartDataSource>(sourceV1)
|
||||
const [mountedA, setMountedA] = useState(true)
|
||||
const a = useChartState()
|
||||
const b = useChartState()
|
||||
return (
|
||||
<Content note="A는 v1, B는 v2로 시작합니다. A의 Source 변경·Unmount·Mount 후에도 B의 데이터와 상태 알림 수가 유지되어야 합니다. 두 차트는 동일한 호스트 HM Theme를 상속하며 각 Shadow DOM에 공개 CSS Asset을 연결합니다.">
|
||||
<div className="hm-orgchart-story-controls">
|
||||
<Button appearance="outline" onClick={() => setSourceA(sourceV1)}>A → JSON v1</Button>
|
||||
<Button appearance="outline" onClick={() => setSourceA(sourceV2)}>A → JSON v2</Button>
|
||||
<Button variant="cancel" appearance="outline" onClick={() => setMountedA(value => !value)}>{mountedA ? "A Unmount" : "A Mount"}</Button>
|
||||
</div>
|
||||
<div className="hm-orgchart-story-instances">
|
||||
<section className="hm-orgchart-story-instance" aria-label="OrgChart A">
|
||||
<StateOutput {...a} label="A 상태" />
|
||||
{mountedA && <OrgChart dataSource={sourceA} stylesheetUrls={stylesheetUrls} onStateChange={a.onStateChange} className="hm-orgchart-story-chart" />}
|
||||
</section>
|
||||
<section className="hm-orgchart-story-instance" aria-label="OrgChart B">
|
||||
<StateOutput {...b} label="B 상태" />
|
||||
<OrgChart dataSource={sourceV2} stylesheetUrls={stylesheetUrls} onStateChange={b.onStateChange} className="hm-orgchart-story-chart" />
|
||||
</section>
|
||||
</div>
|
||||
</Content>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "OrgChart/Content",
|
||||
tags: ["internal"],
|
||||
component: OrgChart,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: { description: { component: "OrgChart v0.1 콘텐츠: React Wrapper → 동일 Vanilla Core → dabeng 엔진 / HM Theme / 노드 UI. 기존 HTML JSON을 공유하며 Navigation·Zoom/Pan·실제 인사 데이터는 포함하지 않습니다." } },
|
||||
},
|
||||
args: { dataSource: sourceV1, stylesheetUrls },
|
||||
argTypes: {
|
||||
dataSource: { control: false },
|
||||
stylesheetUrls: { control: false },
|
||||
aggregationPolicy: { control: false },
|
||||
onStateChange: { control: false },
|
||||
onLifecycleError: { control: false },
|
||||
className: { control: false },
|
||||
style: { control: false },
|
||||
},
|
||||
decorators: [(Story) => <StrictMode><Story /></StrictMode>],
|
||||
} satisfies Meta<typeof OrgChart>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const BasicOrganization: Story = {
|
||||
name: "Basic Organization",
|
||||
render: args => <Content note="HTML 공통 v1: 조직 3개, 고유 직원 4명 (Root 1 / Team A 2 / Team B 1). 정책 미주입 시 원본 집계만 표시하고 업무상 총원은 미확정입니다."><OrgChart {...args} className="hm-orgchart-story-chart" /></Content>,
|
||||
}
|
||||
|
||||
export const OrganizationHierarchy: Story = {
|
||||
name: "Organization Hierarchy",
|
||||
args: { dataSource: hierarchySource },
|
||||
render: args => <Content note="공통 v1의 parentId 계층·조직장과 연결선을 확인합니다. showEmployees/showSummary만 숨기며 원본 소속과 집계는 유지합니다. 하위 조직 접기/펼치기는 Core 버튼의 키보드 Enter/Space로 실행합니다."><OrgChart {...args} className="hm-orgchart-story-chart" /></Content>,
|
||||
}
|
||||
|
||||
export const EmployeeCards: Story = {
|
||||
name: "Employee Cards",
|
||||
args: { dataSource: employeeSource },
|
||||
render: args => <Content note="공통 v1에서 긴 조직명·직원명·직위·직책만 파생한 가상 데이터입니다. null 프로필 이미지는 Core의 첫 글자 대체 표시를 사용합니다. 재직·파견·휴직 상태와 조직장 구분은 동일 Core 노드 UI입니다."><OrgChart {...args} className="hm-orgchart-story-chart" /></Content>,
|
||||
}
|
||||
|
||||
export const OrganizationSummary: Story = {
|
||||
name: "Organization Summary",
|
||||
render: args => <Content note="공통 v1 원본 직원 전체 4명, 직접 인원 Root 1 / Team A 2 / Team B 1. 하위 포함 인원은 서로 합산하지 않습니다. 승인 집계 정책을 임의 생성하지 않으며 업무상 총원은 정책 승인 필요로 표시합니다."><OrgChart {...args} className="hm-orgchart-story-chart" /></Content>,
|
||||
}
|
||||
|
||||
export const JSONDataUpdate: Story = { name: "JSON Data Update", render: () => <DataUpdateContent /> }
|
||||
export const LoadingEmptyError: Story = { name: "Loading / Empty / Error", render: () => <LoadingContent /> }
|
||||
export const ResponsiveContainer: Story = { name: "Responsive Container", render: () => <ResponsiveContent /> }
|
||||
export const MultipleInstances: Story = { name: "Multiple Instances", render: () => <MultipleContent /> }
|
||||
Reference in new issue
Block a user