feat: Initialize organization chart web part with core functionality and configuration

- Added configuration files: config.json, copy-assets.json, package-solution.json, serve.json, write-manifests.json.
- Created gulpfile.js for build process.
- Implemented core logic in OrganizationChartCore.ts for account normalization and profile handling.
- Developed data service in OrganizationChartDataService.ts to fetch organization data from SharePoint User Profile Service.
- Defined types for user profiles and organization nodes in OrganizationChartTypes.ts.
- Created web part manifest for OrganizationChartWebPart.
- Built the main web part component with rendering logic and property pane configuration in OrganizationChartWebPart.ts.
- Developed React components for displaying the organization chart.
- Implemented styling for the organization chart in OrganizationChart.module.scss.
- Added localization support with English and German strings.
- Created unit tests for core functionalities in OrganizationChartCore.test.ts.
- Set up TypeScript and TSLint configurations for the project.
This commit is contained in:
Torsten Brendgen
2026-08-24 13:48:32 +02:00
commit 18fee714b8
27 changed files with 916 additions and 0 deletions
@@ -0,0 +1,13 @@
import { IOrganizationChartData } from '../OrganizationChartTypes';
export interface IOrganizationChartProps {
data: IOrganizationChartData;
title: string;
showPictures: boolean;
showContactDetails: boolean;
reportsLabel: string;
collapseLabel: string;
expandLabel: string;
truncatedLabel: string;
peopleCountLabel: string;
}
@@ -0,0 +1,62 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.organizationChart {
color: "[theme:bodyText, default:#323130]";
font-family: "Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif;
}
.header { align-items: baseline; display: flex; justify-content: space-between; margin-bottom: 12px; }
.heading { font-size: 24px; font-weight: 300; line-height: 32px; margin: 0; }
.count { color: "[theme:neutralSecondary, default:#605e5c]"; font-size: 12px; }
.notice { background: "[theme:neutralLighter, default:#f3f2f1]"; border-left: 3px solid "[theme:themePrimary, default:#0078d4]"; margin-bottom: 12px; padding: 8px 12px; }
.viewport { overflow: auto; padding: 4px 4px 24px; }
.tree { display: table; margin: 0 auto; min-width: max-content; }
.tree ul { display: flex; justify-content: center; list-style: none; margin: 0; padding: 22px 0 0; position: relative; }
.tree li { list-style: none; padding: 22px 8px 0; position: relative; text-align: center; }
.tree > ul { padding-top: 0; }
.tree > ul > li { padding-top: 0; }
.tree li::before, .tree li::after { border-top: 1px solid "[theme:neutralTertiary, default:#a19f9d]"; content: ''; height: 22px; position: absolute; right: 50%; top: 0; width: 50%; }
.tree li::after { border-left: 1px solid "[theme:neutralTertiary, default:#a19f9d]"; left: 50%; right: auto; }
.tree li:only-child::before, .tree li:only-child::after { display: none; }
.tree li:only-child { padding-top: 22px; }
.tree > ul > li:only-child { padding-top: 0; }
.tree li:first-child::before, .tree li:last-child::after { border: 0; }
.tree li:last-child::before { border-right: 1px solid "[theme:neutralTertiary, default:#a19f9d]"; border-radius: 0 4px 0 0; }
.tree li:first-child::after { border-radius: 4px 0 0 0; }
.tree ul ul::before { border-left: 1px solid "[theme:neutralTertiary, default:#a19f9d]"; content: ''; height: 22px; left: 50%; position: absolute; top: 0; }
.nodeWrap { display: inline-block; position: relative; }
.personCard {
background: "[theme:white, default:#ffffff]";
border: 1px solid "[theme:neutralLight, default:#edebe9]";
box-shadow: 0 1px 3px rgba(0, 0, 0, .12);
color: "[theme:bodyText, default:#323130]";
display: block;
min-height: 92px;
text-align: left;
text-decoration: none;
width: 230px;
}
.personCard:hover { border-color: "[theme:themePrimary, default:#0078d4]"; color: "[theme:bodyText, default:#323130]"; }
.personCard:focus { outline: 2px solid "[theme:focusBorder, default:#605e5c]"; outline-offset: 1px; }
.cardContent { align-items: center; display: flex; min-height: 92px; padding: 12px; position: relative; }
.photoWrap { background: "[theme:themePrimary, default:#0078d4]"; border-radius: 50%; flex: 0 0 56px; height: 56px; margin-right: 12px; overflow: hidden; position: relative; }
.initials { color: #fff; font-size: 18px; left: 0; line-height: 56px; position: absolute; right: 0; text-align: center; }
.photo { height: 56px; object-fit: cover; position: relative; width: 56px; z-index: 1; }
.personText { display: block; min-width: 0; padding-bottom: 16px; }
.name { display: block; font-size: 15px; font-weight: 600; line-height: 20px; }
.jobTitle, .department, .email, .location { color: "[theme:neutralSecondary, default:#605e5c]"; display: block; font-size: 12px; line-height: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.email { color: "[theme:themePrimary, default:#0078d4]"; }
.reportCount { bottom: 8px; color: "[theme:neutralSecondary, default:#605e5c]"; font-size: 11px; position: absolute; right: 10px; }
.toggle { background: "[theme:white, default:#ffffff]"; border: 1px solid "[theme:neutralTertiary, default:#a19f9d]"; border-radius: 50%; bottom: -11px; color: "[theme:themePrimary, default:#0078d4]"; cursor: pointer; font-size: 16px; height: 23px; left: calc(50% - 11px); line-height: 18px; padding: 0; position: absolute; width: 23px; z-index: 2; }
.toggle:hover, .toggle:focus { background: "[theme:neutralLighter, default:#f3f2f1]"; outline: none; }
.status { color: "[theme:bodyText, default:#323130]"; padding: 16px; }
.error { color: "[theme:errorText, default:#a80000]"; padding: 16px; }
@media (max-width: 600px) {
.tree { display: block; min-width: 0; width: 100%; }
.tree ul { display: block; padding: 12px 0 0 18px; }
.tree li { padding: 12px 0 0 12px; text-align: left; }
.tree li::before, .tree li::after, .tree ul ul::before { display: none; }
.tree > ul > li, .tree li:only-child { padding-top: 0; }
.personCard { max-width: 100%; width: 260px; }
.toggle { bottom: auto; left: -11px; top: 36px; }
}
@@ -0,0 +1,103 @@
import * as React from 'react';
import { IOrganizationNode, IUserProfile } from '../OrganizationChartTypes';
import styles from './OrganizationChart.module.scss';
import { IOrganizationChartProps } from './IOrganizationChartProps';
interface IOrganizationChartState {
collapsed: { [account: string]: boolean };
}
export default class OrganizationChart extends React.Component<IOrganizationChartProps, IOrganizationChartState> {
public constructor(props: IOrganizationChartProps) {
super(props);
this.state = { collapsed: {} };
}
public render(): React.ReactElement<IOrganizationChartProps> {
return (
<section className={styles.organizationChart} aria-label={this.props.title}>
<div className={styles.header}>
{this.props.title && <h2 className={styles.heading}>{this.props.title}</h2>}
<span className={styles.count}>
{this.props.peopleCountLabel.replace('{0}', String(this.props.data.peopleCount))}
</span>
</div>
{this.props.data.truncated && <div className={styles.notice} role='status'>{this.props.truncatedLabel}</div>}
<div className={styles.viewport}>
<div className={styles.tree}>
<ul role='tree'>{this.renderNode(this.props.data.root, 1)}</ul>
</div>
</div>
</section>
);
}
private renderNode(node: IOrganizationNode, level: number): JSX.Element {
const profile: IUserProfile = node.profile;
const key: string = profile.accountName.toLowerCase();
const collapsed: boolean = this.state.collapsed[key] === true;
const hasChildren: boolean = node.children.length > 0;
return (
<li key={profile.accountName} role='treeitem' aria-level={level}
aria-expanded={hasChildren ? !collapsed : undefined}>
<div className={styles.nodeWrap}>
{this.renderPerson(profile)}
{hasChildren && <button type='button' className={styles.toggle}
title={collapsed ? this.props.expandLabel : this.props.collapseLabel}
aria-label={(collapsed ? this.props.expandLabel : this.props.collapseLabel) + ': ' + profile.displayName}
aria-expanded={!collapsed} onClick={(): void => this.toggle(key)}>
{collapsed ? '+' : '−'}
</button>}
</div>
{hasChildren && !collapsed &&
<ul role='group'>
{node.children.map((child: IOrganizationNode): JSX.Element => this.renderNode(child, level + 1))}
</ul>}
</li>
);
}
private renderPerson(profile: IUserProfile): JSX.Element {
const content: JSX.Element = (
<span className={styles.cardContent}>
{this.props.showPictures && <span className={styles.photoWrap}>
<span className={styles.initials} aria-hidden='true'>{this.initials(profile.displayName)}</span>
{profile.pictureUrl && <img className={styles.photo} src={profile.pictureUrl} alt=''
onError={(event: React.SyntheticEvent<HTMLImageElement>): void => this.hideBrokenImage(event)} />}
</span>}
<span className={styles.personText}>
<span className={styles.name}>{profile.displayName}</span>
{profile.title && <span className={styles.jobTitle}>{profile.title}</span>}
{profile.department && <span className={styles.department}>{profile.department}</span>}
{this.props.showContactDetails && profile.email && <span className={styles.email}>{profile.email}</span>}
{this.props.showContactDetails && (profile.office || profile.location) &&
<span className={styles.location}>{profile.office || profile.location}</span>}
</span>
{profile.directReports.length > 0 &&
<span className={styles.reportCount}>{profile.directReports.length + ' ' + this.props.reportsLabel}</span>}
</span>
);
return profile.profileUrl
? <a className={styles.personCard} href={profile.profileUrl}>{content}</a>
: <div className={styles.personCard}>{content}</div>;
}
private toggle(key: string): void {
const collapsed: { [account: string]: boolean } = {};
const existingKeys: string[] = Object.keys(this.state.collapsed);
for (let i: number = 0; i < existingKeys.length; i++) {
collapsed[existingKeys[i]] = this.state.collapsed[existingKeys[i]];
}
collapsed[key] = !collapsed[key];
this.setState({ collapsed: collapsed });
}
private initials(displayName: string): string {
const parts: string[] = String(displayName || '?').trim().split(/\s+/);
return (parts[0].charAt(0) + (parts.length > 1 ? parts[parts.length - 1].charAt(0) : '')).toUpperCase();
}
private hideBrokenImage(event: React.SyntheticEvent<HTMLImageElement>): void {
event.currentTarget.style.display = 'none';
}
}