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:
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user