Organisation Chart Developer Guide - edmozley/freeitsm GitHub Wiki
The internals of People โ Organisation (4.8.0, #241, PR #242 by vsc55). Read People - Developer Guide first: the chart follows that module's two gates and its peopleScope() visibility rule. The reviews that brought it in: Organisation chart - PR review (#242, and #265 for the script file, the wheel and the totals).
| File | What it does |
|---|---|
includes/people.php (the "Organisation chart" block at the end) |
Every query: peopleOrgLevel(), peopleOrgPath(), peopleOrgPerson(), peopleOrgBranch(), peopleOrgContext(), peopleOrgDepartments(), peopleOrgGaps(), with peopleOrgNodes() and peopleOrgShownSql() beneath them and peopleOrgChartLevels() for the levels a request asks for |
api/people/org.php |
GET only. action = level, path, person, branch, context, gaps. Needs the People module (requireModuleAccessJson('people')) |
people/org.php |
The page: sidebar (find, view, leavers, gaps), the tree, the chart, the side panel, the Mouse and keyboard dialog. Hands the script its server values as window.PEOPLE_ORG
|
assets/js/people-org.js |
All the page's JavaScript - tree, chart, side panel, levels, zoom and drag, export (moved out of the page in #265 so the browser caches it; bump its ?v= in people/org.php on every change) |
assets/css/people.css |
The ppl-org-* classes |
people/includes/header.php, people/person.php
|
The Organisation tab, and Show in the chart on a person's page (only for somebody with a manager or reports) |
tests/people-org.php |
94 checks on the functions, in a transaction that is always rolled back |
tests/people-org-api.php |
43 checks on the endpoint over HTTP: what it makes of what a URL sends |
No schema change. The chart reads users.manager_id (one pointer, pointing up), with job_title, department, is_active, is_managed and the company.
Every function takes the analyst and applies the same rule, so the tree, the chart, the side panel, the gaps and the exports cannot disagree:
-
A node is somebody the reader could open -
peopleScope()on the person andanalystCanAccessUser()on any id that arrives in a request. -
A manager in a company the reader cannot access is never named. The people under them start a branch of their own, as if they had no manager.
peopleOrgLevel(0)- the top - is "people who manage somebody visible and have no manager the reader may see", which is exactly where those branches surface. -
Out of reach reads as nobody. A person the reader cannot see gives
null/ an empty level, and the endpoint answers "Person not found" - never a different error that would confirm the person exists. -
Leavers are hidden unless asked for, except a leaver who still has current people reporting to them (
peopleOrgShownSql()): hiding them would hide the gap.
TRAP: every count runs the same rule.
reportson a node is a subquery with its ownpeopleScope()andpeopleOrgShownSql(), so a manager's number counts only the people this reader would see under them. A plainCOUNT(*)ofmanager_idwould leak how many people sit in a company the reader cannot access.TRAP: placeholder order. In
peopleOrgNodes()thereportssubquery sits in the SELECT list, before the WHERE, so its arguments go first:array_merge($argsR, $argsSR, $whereArgs, $argsU, $argsS). Adding a condition to either part means adding its arguments in the matching place.
Reporting lines come from directories and from hand edits, so they can be wrong: a person their own manager, a loop (A โ B โ A), a chain deeper than any real organisation.
-
Upwards (
peopleOrgPath()): at mostPEOPLE_ORG_MAX_DEPTH(32) steps, with a visited set, stopping at the first manager the reader may not see - or, with leavers hidden, a leaver the tree would not show - which is the same place the tree starts their branch. -
Downwards (
peopleOrgBranch()): bounded by the levels asked for and byPEOPLE_ORG_CHART_MAX(150) boxes in total, withtruncatedsaying people were left out. -
Self-managers (
manager_id = id) are treated as having no manager everywhere (u.id <> ?,u.manager_id = u.idat the top).
Each node carries reports (their own team) and total (everybody under them, at every level). peopleOrgNodes() asks peopleOrgTotals() once for the whole list, and only for the people who have a team:
$totals = peopleOrgTotals($conn, $analystId, array_map('intval', array_column(array_filter($rows, fn($r) => (int)$r['reports'] > 0), 'id')), $leavers);
...
'total' => max((int)$r['reports'], $totals[(int)$r['id']] ?? 0),peopleOrgTotals() is one recursive query (MySQL 8, as Knowledge's folder walk already uses):
WITH RECURSIVE under_them (root, id, depth) AS (
SELECT r.manager_id, r.id, 1 FROM users r
WHERE r.manager_id IN (?, ?, ...) AND r.id <> r.manager_id {scope r} {shown r}
UNION ALL
SELECT s.root, c.id, s.depth + 1 FROM under_them s JOIN users c ON c.manager_id = s.id
WHERE c.id <> c.manager_id AND s.depth < 32 {scope c} {shown c}
)
SELECT root, COUNT(DISTINCT id) AS total FROM under_them WHERE id <> root GROUP BY root-
rootcarries the person being counted for down every level, so one query answers for the whole list. -
Both halves apply
peopleScope()andpeopleOrgShownSql(). A person out of reach is neither counted nor walked through, so the count stops exactly where the tree does - the same rule as thereportsTRAP above. -
depth < PEOPLE_ORG_MAX_DEPTHends a loop in bad data, andCOUNT(DISTINCT id)withid <> rootmakes sure a loop (A โ B โ A) never counts anybody twice or counts the person under themselves. - The walk runs off
idx_users_manager(users.manager_id).
TRAP: placeholder order again. The arguments go
$ids, then the anchor's scope and shown arguments, then the recursive part's:array_merge($ids, $argsR, $argsSR, $argsC, $argsSC)- the order the?appear in the SQL.
| Constant | Value | Why |
|---|---|---|
PEOPLE_ORG_MAX_DEPTH |
32 | How far a line is ever walked; real organisations never get near it |
PEOPLE_ORG_CHART_LEVELS |
3 | Levels below the chosen person when none are asked for |
PEOPLE_ORG_CHART_LEVELS_MAX |
32 | "All" and 0 mean this |
PEOPLE_ORG_CHART_MAX |
150 | Boxes in one chart, past which it is unreadable |
PEOPLE_ORG_LEVEL_LIMIT |
500 | People in one tree level; more says there are others |
PEOPLE_ORG_GAPS_LIMIT |
50 | People named in each list of gaps (the total is exact) |
PEOPLE_ORG_CONTEXT_PEERS |
12 | Colleagues drawn beside each manager on the way up, in In the organisation |
peopleOrgChartLevels() turns whatever a URL sends into one of these: all and 0 (however written, 00 too) are the maximum; anything not a whole number - a list (levels[]=5), hex, padding - is the default; more than the maximum is held to it. The endpoint does the same with id (is_scalar() first, because (int) of an array is 1 - somebody nobody asked for).
api/people/org.php reads and never writes, so it is GET only and needs no CSRF token. Errors are fixed strings; an exception is logged, never echoed. gaps also returns the departments, because the page hands out its colours by them (peopleOrgDepartments(): the current people's departments, sorted, so each keeps its colour wherever it appears).
-
Tree: one
levelrequest per branch opened. The open branches survive F5 insessionStorage(people.org.open- a new tab starts closed); the layouts (Cards / Compact / Columns, chart direction, levels...) are remembered inlocalStorage(people.org.*). Both are wrapped in try/catch, so a browser that blocks storage simply forgets. -
Chart:
branch(their part) orcontext(in the organisation), drawn as HTML boxes and lines. -
Export: the images are drawn from the data, as an SVG built for the purpose, not captured from the screen. The first version used the bundled html2canvas 1.4.1, which cannot parse modern CSS colour functions. PNG comes from that SVG through a canvas; PDF places it, as a picture, through FreeITSM's shared PDF writer (
assets/js/pdf-writer.js,FitsmPdf.create(), on PDFKit since #254) as a JPEG, because a high-quality JPEG of a chart on a white ground looks the same and is far smaller. The browser drew the picture, so every language in it is already right. CSV, JSON and XML are the same people as data. -
Escaping: every value goes through
esc()(which escapes quotes too) before it reachesinnerHTML; ids are numbers from the server. -
The script is a file (
assets/js/people-org.js). It needs only four values from the server, which the page sets first throughjson_encode()with the HTML-safe flags:window.PEOPLE_ORG = { api, list, levelsDefault, levelsMax }. Nothing else in the script is PHP - keep it that way, so it can be cached and read on its own. (While it was inline, the XML declaration in the exports had to be written'<' + '?xml', because<?opens PHP withshort_open_tagon; in a.jsfile it is written whole.) - The wheel scrolls, and only Ctrl/Cmd with the wheel zooms - a trackpad pinch arrives as exactly that:
chartEl.addEventListener('wheel', function (e) {
if (!e.target.closest('.ppl-org-canvas')) return;
if (!e.ctrlKey && !e.metaKey) return; // a plain scroll: the browser's
e.preventDefault();
var dy = e.deltaMode === 1 ? e.deltaY * 33 : e.deltaY;
zoomTo(state.zoom * Math.exp(-Math.max(-300, Math.min(300, dy)) * 0.0015), e.clientX, e.clientY);
}, { passive: false });passive: false is what lets preventDefault() stop the browser zooming the whole page when Ctrl is held over the drawing.
-
drawChart(keepView, keepZoom): a refresh keeps zoom and scroll; a layout switch (Cards / Compact, the direction) passeskeepZoomso the same boxes stay the same size; only a new drawing is fitted to the width. Fitting again on a layout switch could land a slightly narrower Compact drawing on the next 10% step, making it look bigger.
-
A new field on a node: select it in
peopleOrgNodes()and map it in thearray_mapat its end. Ask first whether the person page shows the same reader that field -peopleOrgPerson()deliberately returns only whatpersonDetail()already shows, so the chart never tells anybody anything new. -
A new action: add the function in
includes/people.phptaking$analystIdand applyingpeopleScope()/analystCanAccessUser()like the others, acasein the endpoint that returns "Person not found" for null, and checks in both tests - including a reader who cannot see the person. - Writing reporting lines from the chart is deliberately not here. Lines are owned by the directory, or by Tickets โ Users; a chart that edited them would be overwritten by the next import.
FREEITSM_TEST_DATABASE=<throwaway db> php tests/people-org.php
FREEITSM_TEST_URL=http://localhost/<install> FREEITSM_TEST_DATABASE=<its db> php tests/people-org-api.php
people-org.php builds two companies and a small organisation - a manager in the other company, leavers with and without reports, a reporting line that loops, a long chain and a manager of 160 people - inside a transaction that is always rolled back. people-org-api.php cannot use a transaction - the web server must see its rows - so it tags every row it makes and deletes them in finally, and stops first if the server cannot see them. Both were run on the test install during the review: 89 and 43 checks, all passing; after #265, 94 and 43. The five new checks count with and without leavers, across companies, through a loop, and for somebody with nobody under them.