@asd20/ui-next 2.36.0 → 2.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ # [2.38.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.37.0...ui-next-v2.38.0) (2026-11-11)
4
+
5
+
6
+ ### Features
7
+
8
+ * add organization email and omit missing public data ([8b1b8a7](https://github.com/academydistrict20/asd20-ui-next/commit/8b1b8a79a6b6ea04b8925cce517d022c839a1955))
9
+
10
+ # [2.37.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.36.0...ui-next-v2.37.0) (2026-11-10)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * use generic organization labels for message data ([c4c7c5a](https://github.com/academydistrict20/asd20-ui-next/commit/c4c7c5ac0d5857966cfaccabb9d5f8c0fb7ed99a))
16
+
17
+
18
+ ### Features
19
+
20
+ * resolve shared message data in the current school context ([808af89](https://github.com/academydistrict20/asd20-ui-next/commit/808af89ac9e8d01395aafc770c02c9e9fbeda5e0))
21
+ * support configurable live Team rosters ([bc32c32](https://github.com/academydistrict20/asd20-ui-next/commit/bc32c325f4dcfd5ea13856ce90da081e093de95e))
22
+
3
23
  # [2.36.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.35.4...ui-next-v2.36.0) (2026-11-10)
4
24
 
5
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.36.0",
3
+ "version": "2.38.0",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -6,7 +6,7 @@
6
6
  @click="onContentClick"
7
7
  >
8
8
  <p
9
- v-if="liveDataError && !liveDataProvider"
9
+ v-if="showLiveDataDiagnostics && liveDataError && !liveDataProvider"
10
10
  role="status"
11
11
  >
12
12
  {{ liveDataError }} <button
@@ -76,6 +76,7 @@ export default {
76
76
  props: {
77
77
  bodyContent: { type: String, default: '' },
78
78
  bodyJson: { type: [String, Object], default: '' },
79
+ organizationId: { type: String, default: '' },
79
80
  headingLevelOffset: { type: Number, default: 0 },
80
81
  headingIdPrefix: { type: String, default: '' },
81
82
  },
@@ -4,7 +4,7 @@ import {
4
4
  contextualAccordionItemKey,
5
5
  normalizeHeadingContext,
6
6
  } from './headingContext.js'
7
- import { validateLiveDataReference, liveDataLabel } from '../../../helpers/liveMessageData.js'
7
+ import { validateLiveDataReference } from '../../../helpers/liveMessageData.js'
8
8
 
9
9
  const MESSAGE_DOCUMENT_SCHEMA_VERSION = 1
10
10
 
@@ -96,8 +96,8 @@ const MARK_RENDER_ORDER = Object.freeze({
96
96
  superscript: 6,
97
97
  })
98
98
  const NODE_ATTRIBUTE_NAMES = Object.freeze({
99
- liveDataField: ['source', 'sourceId', 'field'],
100
- liveDataBlock: ['source', 'sourceId', 'field'],
99
+ liveDataField: ['source', 'sourceId', 'field', 'binding', 'teamRole'],
100
+ liveDataBlock: ['source', 'sourceId', 'field', 'binding', 'teamRole', 'memberFields', 'memberOrder', 'memberSort'],
101
101
  doc: ['schemaVersion'],
102
102
  text: [],
103
103
  hardBreak: [],
@@ -807,8 +807,7 @@ function renderImage(attrs, className = '') {
807
807
 
808
808
  function renderStandardNode(node, context, path, accordionItemId) {
809
809
  if (node.type === 'liveDataField' || node.type === 'liveDataBlock') {
810
- const tag = node.type === 'liveDataField' ? 'span' : 'div'
811
- return `<${tag} class="asd20-live-data-unavailable">${escapeHtml(liveDataLabel(node.attrs))} is currently unavailable.</${tag}>`
810
+ return ''
812
811
  }
813
812
  if (node.type === 'text') return renderMarkedText(node, context, path)
814
813
  if (node.type === 'hardBreak') return '<br>'
@@ -147,7 +147,7 @@
147
147
  >
148
148
  <slot name="sidebar-status" />
149
149
  <p
150
- v-if="liveDataError && !liveDataManaged"
150
+ v-if="showLiveDataDiagnostics && liveDataError && !liveDataManaged"
151
151
  role="status"
152
152
  >
153
153
  {{ liveDataError }} <button
@@ -298,7 +298,7 @@ import { installDocumentPrintLifecycle } from '../../../helpers/documentPrint'
298
298
  import { shouldHandleClientRoutingEvent } from '../../../helpers/linkPolicy'
299
299
  import pageTemplateMixin from '../../../mixins/pageTemplateMixin'
300
300
  import { publicationHeadingPlanKey } from '../../atoms/Asd20RichBodyContent/headingContext'
301
- import { liveMessageDataMixin, liveMessageDataProviderKey } from '../../../helpers/liveMessageDataRuntime'
301
+ import { liveMessageDataMixin, liveMessageDataProviderKey, liveMessageDataOrganizationKey } from '../../../helpers/liveMessageDataRuntime'
302
302
 
303
303
  export default {
304
304
  name: 'Asd20HandbookTemplate',
@@ -320,6 +320,7 @@ export default {
320
320
  return {
321
321
  [publicationHeadingPlanKey]: () => this.publicationHeadings.idPlan,
322
322
  [liveMessageDataProviderKey]: () => ({ snapshot: this.liveDataSnapshot }),
323
+ [liveMessageDataOrganizationKey]: () => this.liveDataContextOrganizationId,
323
324
  }
324
325
  },
325
326
  props: {
@@ -3,19 +3,22 @@
3
3
 
4
4
  // Canonical public live-data contract. The shared UI's copy is generated by
5
5
  // scripts/sync-live-message-data.mjs; never add arbitrary paths or queries here.
6
- const LIVE_DATA_VERSION = 1;
6
+ const LIVE_DATA_VERSION = 4;
7
+ const ADMINISTRATION_TEAM_TAG = "asd20:handbook:administration";
8
+ const LIVE_DATA_MEMBER_FIELDS = Object.freeze({ name: "Name", title: "Title", phone: "Phone" });
7
9
  const LIVE_DATA_FIELDS = Object.freeze({
8
10
  organization: Object.freeze({
9
- title: "School name",
10
- address: "School address",
11
- phone: "School main phone",
12
- hours: "School office hours",
13
- administrators: "School administrators",
11
+ title: "Organization Name",
12
+ address: "Organization Address",
13
+ phone: "Organization Main Phone",
14
+ email: "Organization Email",
15
+ hours: "Organization Office Hours",
16
+ administrators: "Organization Administrators",
14
17
  }),
15
18
  team: Object.freeze({
16
- contactPhone: "Team contact phone",
17
- officeHours: "Team office hours",
18
- staff: "Team staff list",
19
+ contactPhone: "Team Contact Phone",
20
+ officeHours: "Team Office Hours",
21
+ staff: "Team Staff List",
19
22
  }),
20
23
  });
21
24
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -24,23 +27,41 @@ const LIVE_DATA_NODE_TYPES = new Set(["liveDataField", "liveDataBlock"]);
24
27
  function validateLiveDataReference(node) {
25
28
  const attrs = node?.attrs;
26
29
  if (!LIVE_DATA_NODE_TYPES.has(node?.type) || !attrs ||
27
- Object.keys(attrs).some(key => !["source", "sourceId", "field"].includes(key)) ||
30
+ Object.keys(attrs).some(key => !["source", "sourceId", "field", "binding", "teamRole", "memberFields", "memberOrder", "memberSort"].includes(key)) ||
28
31
  typeof attrs.source !== "string" || typeof attrs.field !== "string" ||
29
32
  !Object.hasOwn(LIVE_DATA_FIELDS, attrs.source) ||
30
33
  !Object.hasOwn(LIVE_DATA_FIELDS[attrs.source], attrs.field) ||
31
- typeof attrs.sourceId !== "string" || !UUID.test(attrs.sourceId) || node.content?.length || node.marks?.length) {
34
+ node.content?.length || node.marks?.length) {
32
35
  return false;
33
36
  }
37
+ const binding = attrs.binding ?? "fixed";
38
+ if (!["fixed", "currentSchool"].includes(binding)) return false;
39
+ if (binding === "fixed") {
40
+ if (typeof attrs.sourceId !== "string" || !UUID.test(attrs.sourceId) || attrs.teamRole != null) return false;
41
+ } else if ((attrs.sourceId != null && attrs.sourceId !== "") ||
42
+ (attrs.source === "team" ? attrs.teamRole !== "administration" : attrs.teamRole != null)) return false;
43
+ if (attrs.memberFields != null && (!Array.isArray(attrs.memberFields) || !attrs.memberFields.length ||
44
+ attrs.memberFields.length > 3 || new Set(attrs.memberFields).size !== attrs.memberFields.length ||
45
+ attrs.memberFields.some(field => !Object.hasOwn(LIVE_DATA_MEMBER_FIELDS, field)))) return false;
46
+ if (attrs.memberOrder != null && (!Array.isArray(attrs.memberOrder) || attrs.memberOrder.length > 100 ||
47
+ attrs.memberOrder.some(id => typeof id !== "string" || !UUID.test(id)) ||
48
+ new Set(attrs.memberOrder.map(id => id.toLowerCase())).size !== attrs.memberOrder.length)) return false;
49
+ if (attrs.memberSort != null && !["alphabetical", "custom", "administration"].includes(attrs.memberSort)) return false;
50
+ if (attrs.memberOrder?.length && (binding === "currentSchool" || (attrs.memberSort != null && attrs.memberSort !== "custom"))) return false;
51
+ if (binding === "currentSchool" && attrs.memberSort === "custom") return false;
52
+ if ((attrs.memberFields != null || attrs.memberOrder != null || attrs.memberSort != null) && !(attrs.source === "team" && attrs.field === "staff")) return false;
34
53
  const block = ["staff", "administrators"].includes(attrs.field);
35
54
  return node.type === (block ? "liveDataBlock" : "liveDataField");
36
55
  }
37
56
 
38
57
  function liveDataLabel(attrs) {
39
- return LIVE_DATA_FIELDS[attrs?.source]?.[attrs?.field] || "Live information";
58
+ const label = LIVE_DATA_FIELDS[attrs?.source]?.[attrs?.field] || "Live information";
59
+ return attrs?.binding === "currentSchool" ? `Current Organization · ${label}` : label;
40
60
  }
41
61
 
42
62
  function liveDataKey(attrs) {
43
- return `${attrs.source}:${attrs.sourceId.toLowerCase()}:${attrs.field}`;
63
+ const base = `${attrs.source}:${attrs.binding === "currentSchool" ? `currentSchool:${attrs.teamRole || ""}` : attrs.sourceId.toLowerCase()}:${attrs.field}`;
64
+ return attrs.field === "staff" ? `${base}:${JSON.stringify([attrs.memberFields ?? Object.keys(LIVE_DATA_MEMBER_FIELDS), (attrs.memberOrder || []).map(id => id.toLowerCase()), attrs.memberSort || (attrs.memberOrder?.length ? "custom" : "alphabetical")])}` : base;
44
65
  }
45
66
 
46
67
  function collectLiveDataReferences(bodyJson) {
@@ -70,11 +91,67 @@ const dataText = value => typeof value === "string" ? value.trim().slice(0, 1200
70
91
  const textNode = text => ({ type: "text", text });
71
92
  const paragraph = text => ({ type: "paragraph", content: [textNode(text)] });
72
93
 
73
- function liveDataContent(attrs, snapshot) {
94
+ function liveDataSource(attrs, snapshot, organizationId = snapshot?.contextOrganizationId) {
74
95
  const collection = attrs.source === "organization" ? snapshot?.organizations : snapshot?.teams;
75
- const source = collection?.find(item => item.id?.toLowerCase() === attrs.sourceId.toLowerCase());
96
+ let source;
97
+ if (attrs.binding === "currentSchool") {
98
+ if (typeof organizationId !== "string" || !UUID.test(organizationId)) return null;
99
+ const id = organizationId.toLowerCase();
100
+ if (attrs.source === "organization") source = collection?.find(item => item.id?.toLowerCase() === id);
101
+ else {
102
+ const candidates = collection?.filter(item => item.ownerOrganizationId?.toLowerCase() === id && item.isPublic === true &&
103
+ item.ownerOrganization?.status === "Published" && Array.isArray(item.tags) && item.tags.includes(ADMINISTRATION_TEAM_TAG));
104
+ if (candidates?.length !== 1) return null;
105
+ source = candidates[0];
106
+ }
107
+ } else source = collection?.find(item => item.id?.toLowerCase() === attrs.sourceId.toLowerCase());
76
108
  if (!source || (attrs.source === "organization" && source.status !== "Published") ||
77
109
  (attrs.source === "team" && (source.isPublic !== true || source.ownerOrganization?.status !== "Published"))) return null;
110
+ return source;
111
+ }
112
+
113
+ // Public roster values are presentation data, never authored attributes. Order
114
+ // uses stable person IDs; departed members disappear and new members sort last.
115
+ function administrationRoleRank(title) {
116
+ const parts = dataText(title).normalize("NFKC").toLowerCase().split(/[,;|/()–—]|\s+-\s+/)
117
+ .map(part => part.replace(/[^\p{L}\p{N}]+/gu, " ").trim());
118
+ // Match whole role segments: an assistant/secretary to the Principal is not
119
+ // the Principal, and Assistant Principal must never match Principal first.
120
+ const ranks = parts.map(part => {
121
+ part = part.replace(/^(?:(?:alternative|online|home|elementary|middle|high|junior|secondary|primary|school|k 8)\s+)+/, "");
122
+ if (/^(?:(?:elementary|middle|high|junior high) school )?(?:assistant|asst|associate|vice) principals?(?: (?:elementary|middle|high|junior high) school)?$/.test(part)) return 1;
123
+ if (/^(?:(?:elementary|middle|high|junior high) school )?principals?(?: (?:elementary|middle|high|junior high) school)?$/.test(part) || part === "head of school") return 0;
124
+ if (/^(?:school |student )?dean(?: of students| of student affairs)?$/.test(part)) return 2;
125
+ return 3;
126
+ });
127
+ return Math.min(...ranks, 3);
128
+ }
129
+
130
+ function liveDataMembers(attrs, snapshot, { organizationId } = {}) {
131
+ const source = liveDataSource(attrs, snapshot, organizationId);
132
+ if (!source) return [];
133
+ const entries = attrs.field === "staff" ? source.people : source.administrators;
134
+ if (!Array.isArray(entries) || entries.length > 100) return [];
135
+ const unique = new Map();
136
+ entries.forEach(entry => {
137
+ const person = attrs.field === "staff" ? entry?.person : entry;
138
+ if (!person || (attrs.field === "staff" && person.isPublic !== true)) return;
139
+ const name = dataText(person.displayName) || [dataText(person.firstName), dataText(person.lastName)].filter(Boolean).join(" ");
140
+ if (!name) return;
141
+ const member = { id: dataText(person.id).toLowerCase(), name, title: dataText(person.title), phone: dataText(person.phone) };
142
+ const key = member.id || JSON.stringify(member);
143
+ if (!unique.has(key)) unique.set(key, member);
144
+ });
145
+ const order = new Map((attrs.memberOrder || []).map((id, index) => [id.toLowerCase(), index]));
146
+ const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
147
+ const rank = member => attrs.memberSort === "administration" ? administrationRoleRank(member.title) :
148
+ attrs.memberSort === "alphabetical" ? 0 : order.get(member.id) ?? Infinity;
149
+ return [...unique.values()].sort((a, b) => rank(a) - rank(b) || compare(a.name, b.name) || compare(a.id, b.id));
150
+ }
151
+
152
+ function liveDataContent(attrs, snapshot, organizationId) {
153
+ const source = liveDataSource(attrs, snapshot, organizationId);
154
+ if (!source) return null;
78
155
  if (attrs.field === "address") {
79
156
  const address = source.address || {};
80
157
  const value = [dataText(address.street1),
@@ -83,18 +160,9 @@ function liveDataContent(attrs, snapshot) {
83
160
  return value ? [textNode(value)] : null;
84
161
  }
85
162
  if (["staff", "administrators"].includes(attrs.field)) {
86
- const people = attrs.field === "staff"
87
- ? (source.people || []).map(item => item.person).filter(person => person?.isPublic === true)
88
- : source.administrators;
89
- if (!Array.isArray(people) || people.length > 100) return null;
90
- const unique = new Map();
91
- people.forEach(person => {
92
- const name = dataText(person?.displayName) || [dataText(person?.firstName), dataText(person?.lastName)].filter(Boolean).join(" ");
93
- if (!name) return;
94
- const line = [name, dataText(person.title), dataText(person.phone)].filter(Boolean).join(" — ");
95
- unique.set(person.id || line, line);
96
- });
97
- const lines = [...unique.values()].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
163
+ const fields = attrs.memberFields ?? Object.keys(LIVE_DATA_MEMBER_FIELDS);
164
+ const lines = liveDataMembers(attrs, snapshot, { organizationId }).map(member => Object.keys(LIVE_DATA_MEMBER_FIELDS)
165
+ .filter(field => fields.includes(field)).map(field => member[field]).filter(Boolean).join(" — ")).filter(Boolean);
98
166
  return lines.length ? [{ type: "bulletList", content: lines.map(line => ({ type: "listItem", content: [paragraph(line)] })) }] : null;
99
167
  }
100
168
  const value = dataText(source[attrs.field]);
@@ -103,34 +171,47 @@ function liveDataContent(attrs, snapshot) {
103
171
 
104
172
  // Materialization returns a copy containing only ordinary document nodes.
105
173
  // Persist the authored references, never this presentation projection.
106
- function resolveLiveDataDocument(bodyJson, snapshot, { strict = true } = {}) {
174
+ function resolveLiveDataDocument(bodyJson, snapshot, { strict = true, organizationId, unavailable = "message" } = {}) {
107
175
  collectLiveDataReferences(bodyJson);
108
176
  const value = typeof bodyJson === "string" ? JSON.parse(bodyJson || "null") : bodyJson;
109
177
  const diagnostics = [];
110
178
  function resolve(node) {
111
179
  if (LIVE_DATA_NODE_TYPES.has(node?.type)) {
112
- const content = liveDataContent(node.attrs, snapshot);
180
+ const content = liveDataContent(node.attrs, snapshot, organizationId);
113
181
  if (content) return content;
114
182
  diagnostics.push({ code: "live-data-unavailable", reference: liveDataKey(node.attrs) });
183
+ if (unavailable === "omit") return [];
115
184
  const message = `${liveDataLabel(node.attrs)} is currently unavailable.`;
116
185
  return node.type === "liveDataBlock" ? [paragraph(message)] : [textNode(message)];
117
186
  }
118
187
  if (!node || typeof node !== "object") return [node];
188
+ const content = Array.isArray(node.content) ? node.content.flatMap(resolve) : undefined;
189
+ if (unavailable === "omit" && node.type === "heading" && content?.length === 0 &&
190
+ node.content.some(child => LIVE_DATA_NODE_TYPES.has(child.type))) return [];
119
191
  return [{ ...node, ...(node.attrs ? { attrs: { ...node.attrs } } : {}),
120
- ...(Array.isArray(node.content) ? { content: node.content.flatMap(resolve) } : {}) }];
192
+ ...(content ? { content } : {}) }];
121
193
  }
122
194
  const document = resolve(value)[0];
195
+ // Keep an all-missing projection renderable without falling back to authored
196
+ // HTML. This placeholder is presentation-only and never saved to the message.
197
+ if (unavailable === "omit" && diagnostics.length && document?.type === "doc" && document.content?.length === 0)
198
+ document.content = [{ type: "paragraph" }];
123
199
  if (strict && diagnostics.length) throw new Error("Required live information is unavailable. Review the message data sources.");
124
200
  return { document, diagnostics };
125
201
  }
126
202
 
127
203
  // One anonymous query captures every dependency for a document batch. No admin
128
204
  // secret, user session, arbitrary field names, or private employee data is used.
129
- async function fetchLiveDataSnapshot(documents, { endpoint, fetchImpl = globalThis.fetch, signal } = {}) {
205
+ async function fetchLiveDataSnapshot(documents, { endpoint, fetchImpl = globalThis.fetch, signal, organizationId } = {}) {
130
206
  const references = new Map();
131
207
  documents.forEach(document => collectLiveDataReferences(document).forEach(attrs => references.set(liveDataKey(attrs), attrs)));
132
- const organizationIds = [...new Set([...references.values()].filter(ref => ref.source === "organization").map(ref => ref.sourceId.toLowerCase()))].sort();
133
- const teamIds = [...new Set([...references.values()].filter(ref => ref.source === "team").map(ref => ref.sourceId.toLowerCase()))].sort();
208
+ const current = [...references.values()].filter(ref => ref.binding === "currentSchool");
209
+ if (current.length && (typeof organizationId !== "string" || !UUID.test(organizationId))) throw new Error("Choose an organization to preview current-organization information.");
210
+ const organizationIds = [...new Set([...references.values()].filter(ref => ref.source === "organization" && ref.binding !== "currentSchool").map(ref => ref.sourceId.toLowerCase()))];
211
+ if (current.length) organizationIds.push(organizationId.toLowerCase());
212
+ const uniqueOrganizationIds = [...new Set(organizationIds)].sort();
213
+ const teamIds = [...new Set([...references.values()].filter(ref => ref.source === "team" && ref.binding !== "currentSchool").map(ref => ref.sourceId.toLowerCase()))].sort();
214
+ const currentTeam = current.some(ref => ref.source === "team");
134
215
  if (organizationIds.length + teamIds.length > 50 || references.size > 100) throw new Error("Too many live data sources.");
135
216
  if (!references.size) return { version: LIVE_DATA_VERSION, organizations: [], teams: [] };
136
217
  if (!endpoint) throw new Error("Public message data is not configured.");
@@ -138,27 +219,27 @@ async function fetchLiveDataSnapshot(documents, { endpoint, fetchImpl = globalTh
138
219
  method: "POST", credentials: "omit", redirect: "error", cache: "no-store", signal,
139
220
  headers: { "Content-Type": "application/json", "Cache-Control": "no-cache" },
140
221
  body: JSON.stringify({
141
- query: `query liveMessageData($organizationIds: [uuid!]!, $teamIds: [uuid!]!) {
222
+ query: `query liveMessageData($organizationIds: [uuid!]!, $teamIds: [uuid!]!${currentTeam ? ", $contextOrganizationIds: [uuid!]!" : ""}) {
142
223
  organizations(where: {id: {_in: $organizationIds}, status: {_eq: "Published"}}) {
143
- id status title address phone hours administrators
224
+ id status title address phone email hours administrators
144
225
  }
145
- teams: groups(where: {id: {_in: $teamIds}, isPublic: {_eq: true}, ownerOrganization: {status: {_eq: "Published"}}}) {
146
- id title isPublic contactPhone officeHours ownerOrganization { status }
226
+ teams: groups(where: {${currentTeam ? `_or: [{id: {_in: $teamIds}}, {ownerOrganizationId: {_in: $contextOrganizationIds}, tags: {_contains: ["${ADMINISTRATION_TEAM_TAG}"]}}]` : "id: {_in: $teamIds}"}, isPublic: {_eq: true}, ownerOrganization: {status: {_eq: "Published"}}}, limit: 101) {
227
+ id title isPublic contactPhone officeHours ownerOrganizationId tags ownerOrganization { status }
147
228
  people(where: {person: {isPublic: {_eq: true}}}, order_by: {person: {id: asc}}, limit: 101) {
148
229
  person { id displayName firstName lastName title phone isPublic }
149
230
  }
150
231
  }
151
- }`, variables: { organizationIds, teamIds },
232
+ }`, variables: { organizationIds: uniqueOrganizationIds, teamIds, ...(currentTeam ? { contextOrganizationIds: [organizationId.toLowerCase()] } : {}) },
152
233
  }),
153
234
  });
154
235
  if (!response.ok) throw new Error("Public message data is unavailable.");
155
236
  const result = await response.json();
156
- if (result.errors || !Array.isArray(result.data?.organizations) || !Array.isArray(result.data?.teams)) throw new Error("Public message data is unavailable.");
157
- return { version: LIVE_DATA_VERSION, organizations: result.data.organizations, teams: result.data.teams };
237
+ if (result.errors || !Array.isArray(result.data?.organizations) || !Array.isArray(result.data?.teams) || result.data.teams.length > 100) throw new Error("Public message data is unavailable.");
238
+ return { version: LIVE_DATA_VERSION, ...(current.length ? { contextOrganizationId: organizationId.toLowerCase() } : {}), organizations: result.data.organizations, teams: result.data.teams };
158
239
  }
159
240
 
160
241
  export {
161
- LIVE_DATA_VERSION, LIVE_DATA_FIELDS, LIVE_DATA_NODE_TYPES,
242
+ LIVE_DATA_VERSION, LIVE_DATA_FIELDS, LIVE_DATA_NODE_TYPES, LIVE_DATA_MEMBER_FIELDS, ADMINISTRATION_TEAM_TAG,
162
243
  validateLiveDataReference, liveDataLabel, liveDataKey,
163
- collectLiveDataReferences, resolveLiveDataDocument, fetchLiveDataSnapshot,
244
+ collectLiveDataReferences, resolveLiveDataDocument, fetchLiveDataSnapshot, liveDataMembers, administrationRoleRank,
164
245
  };
@@ -3,18 +3,25 @@ import { resolveRuntimeConfigValue } from './runtimeConfig'
3
3
 
4
4
  export const liveMessageDataEndpointKey = 'liveMessageDataEndpoint'
5
5
  export const liveMessageDataProviderKey = 'liveMessageDataProvider'
6
+ export const liveMessageDataOrganizationKey = 'liveMessageDataOrganization'
6
7
 
7
8
  // The host supplies liveDataDocuments and may provide a batch snapshot to its
8
9
  // descendants. Only references trigger a request; ordinary messages are inert.
9
10
  export const liveMessageDataMixin = {
10
11
  inject: {
12
+ showLiveDataDiagnostics: { from: 'liveMessageDataDiagnostics', default: false },
11
13
  liveDataEndpoint: { from: liveMessageDataEndpointKey, default: '' },
12
14
  liveDataProvider: { from: liveMessageDataProviderKey, default: null },
15
+ liveDataOrganizationProvider: { from: liveMessageDataOrganizationKey, default: null },
13
16
  },
14
17
  data: () => ({ liveDataSnapshot: null, liveDataLoading: false, liveDataError: '', liveDataEpoch: 0, liveDataMounted: false, liveDataController: null }),
15
18
  computed: {
19
+ liveDataContextOrganizationId() { return this.organizationId || (this.liveDataOrganizationProvider ? this.liveDataOrganizationProvider() : this.organization?.id) || '' },
16
20
  liveDataReferencesKey() {
17
- try { return JSON.stringify(this.liveDataDocuments.flatMap(doc => collectLiveDataReferences(doc))) }
21
+ try {
22
+ const references = this.liveDataDocuments.flatMap(doc => collectLiveDataReferences(doc))
23
+ return references.length ? JSON.stringify([references, references.some(ref => ref.binding === 'currentSchool') ? this.liveDataContextOrganizationId : '']) : '[]'
24
+ }
18
25
  catch { return '' }
19
26
  },
20
27
  effectiveLiveDataSnapshot() { return this.liveDataProvider ? this.liveDataProvider().snapshot : this.liveDataSnapshot },
@@ -38,10 +45,11 @@ export const liveMessageDataMixin = {
38
45
  const snapshot = await fetchLiveDataSnapshot(this.liveDataDocuments, {
39
46
  endpoint: this.liveDataEndpoint || resolveRuntimeConfigValue({ runtimeConfig: this.$config, runtimeConfigKeys: ['graphqlEndpoint'] }),
40
47
  signal: controller.signal,
48
+ organizationId: this.liveDataContextOrganizationId,
41
49
  })
42
- // Validate availability before accepting a partial capture.
50
+ // Validate references while retaining available fields in a partial capture.
43
51
  this.liveDataDocuments.forEach(doc => {
44
- if (collectLiveDataReferences(doc).length) resolveLiveDataDocument(doc, snapshot)
52
+ if (collectLiveDataReferences(doc).length) resolveLiveDataDocument(doc, snapshot, { strict: false, organizationId: this.liveDataContextOrganizationId })
45
53
  })
46
54
  if (epoch === this.liveDataEpoch) this.liveDataSnapshot = snapshot
47
55
  } catch { if (epoch === this.liveDataEpoch) this.liveDataError = 'Live information is currently unavailable.' }
@@ -50,7 +58,7 @@ export const liveMessageDataMixin = {
50
58
  projectLiveMessageData(bodyJson) {
51
59
  try {
52
60
  if (!collectLiveDataReferences(bodyJson).length) return bodyJson
53
- return resolveLiveDataDocument(bodyJson, this.effectiveLiveDataSnapshot, { strict: false }).document
61
+ return resolveLiveDataDocument(bodyJson, this.effectiveLiveDataSnapshot, { strict: false, organizationId: this.liveDataContextOrganizationId, unavailable: this.showLiveDataDiagnostics ? 'message' : 'omit' }).document
54
62
  } catch { return bodyJson }
55
63
  },
56
64
  },
@@ -2,6 +2,8 @@ import responsiveBreakpointMixin from './responsiveBreakpointMixin'
2
2
  import { buildBreadcrumbLinks } from '../helpers/breadcrumbLinks'
3
3
 
4
4
  export default {
5
+ inject: { pageLiveDataOrganizationProvider: { from: 'liveMessageDataOrganization', default: null } },
6
+ provide() { return { liveMessageDataOrganization: () => this.pageLiveDataOrganizationProvider ? this.pageLiveDataOrganizationProvider() : this.organization?.id || '' } },
5
7
  mixins: [responsiveBreakpointMixin],
6
8
 
7
9
  data: () => ({