@asd20/ui-next 2.35.4 → 2.36.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,12 @@
1
1
  # Changelog
2
2
 
3
+ # [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
+
5
+
6
+ ### Features
7
+
8
+ * resolve public live message fields and team rosters ([09beb75](https://github.com/academydistrict20/asd20-ui-next/commit/09beb75f45ef323a65ecf718a3e21b71c3807869))
9
+
3
10
  ## [2.35.4](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.35.3...ui-next-v2.35.4) (2026-11-09)
4
11
 
5
12
 
package/helpers.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { default as arraySearch } from './src/helpers/arraySearch.js'
2
+ export { LIVE_DATA_VERSION, LIVE_DATA_FIELDS, collectLiveDataReferences, resolveLiveDataDocument, fetchLiveDataSnapshot } from './src/helpers/liveMessageData.js'
2
3
  export { buildPublicationIntroduction } from './src/helpers/publicationIntroduction.js'
3
4
  export { prepareDocumentPrint, printDocument, installDocumentPrintLifecycle } from './src/helpers/documentPrint.js'
4
5
  export { default as buildFileSearchUrl } from './src/helpers/buildFileSearchUrl.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.35.4",
3
+ "version": "2.36.0",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -5,6 +5,17 @@
5
5
  :data-message-document-status="renderModel.status"
6
6
  @click="onContentClick"
7
7
  >
8
+ <p
9
+ v-if="liveDataError && !liveDataProvider"
10
+ role="status"
11
+ >
12
+ {{ liveDataError }} <button
13
+ type="button"
14
+ @click="refreshLiveMessageData"
15
+ >
16
+ Retry
17
+ </button>
18
+ </p>
8
19
  <template v-for="(node, index) in renderNodes">
9
20
  <div
10
21
  v-if="node.type === 'html' && node.html"
@@ -50,11 +61,13 @@ import {
50
61
  containsBodyBlockPlaceholder,
51
62
  } from './messageDocumentRenderer'
52
63
  import { publicationHeadingPlanKey } from './headingContext'
64
+ import { liveMessageDataMixin } from '../../../helpers/liveMessageDataRuntime'
53
65
 
54
66
  export default {
55
67
  name: 'Asd20RichBodyContent',
56
68
 
57
69
  components: { Asd20BodyAccordion, Asd20TableauEmbed },
70
+ mixins: [liveMessageDataMixin],
58
71
 
59
72
  inject: {
60
73
  publicationHeadingPlan: { from: publicationHeadingPlanKey, default: null },
@@ -74,10 +87,11 @@ export default {
74
87
  }),
75
88
 
76
89
  computed: {
90
+ liveDataDocuments() { return [this.bodyJson] },
77
91
  renderModel() {
78
92
  return buildRichBodyRenderModel({
79
93
  bodyContent: this.bodyContent,
80
- bodyJson: this.bodyJson,
94
+ bodyJson: this.projectLiveMessageData(this.bodyJson),
81
95
  headingContext: {
82
96
  levelOffset: this.headingLevelOffset,
83
97
  idPrefix: this.headingIdPrefix,
@@ -4,6 +4,7 @@ import {
4
4
  contextualAccordionItemKey,
5
5
  normalizeHeadingContext,
6
6
  } from './headingContext.js'
7
+ import { validateLiveDataReference, liveDataLabel } from '../../../helpers/liveMessageData.js'
7
8
 
8
9
  const MESSAGE_DOCUMENT_SCHEMA_VERSION = 1
9
10
 
@@ -24,8 +25,9 @@ export const MESSAGE_DOCUMENT_STATUS = Object.freeze({
24
25
  const LEGACY_PLACEHOLDER_PATTERN =
25
26
  /<div\b(?=[^>]*\bdata-cc-block=(['"])([^'"]+)\1)(?=[^>]*\bdata-block-id=(['"])([^'"]+)\3)[^>]*><\/div>/gi
26
27
 
27
- const INLINE_NODE_TYPES = new Set(['text', 'hardBreak'])
28
+ const INLINE_NODE_TYPES = new Set(['text', 'hardBreak', 'liveDataField'])
28
29
  const STANDARD_BLOCK_NODE_TYPES = new Set([
30
+ 'liveDataBlock',
29
31
  'paragraph',
30
32
  'heading',
31
33
  'blockquote',
@@ -94,6 +96,8 @@ const MARK_RENDER_ORDER = Object.freeze({
94
96
  superscript: 6,
95
97
  })
96
98
  const NODE_ATTRIBUTE_NAMES = Object.freeze({
99
+ liveDataField: ['source', 'sourceId', 'field'],
100
+ liveDataBlock: ['source', 'sourceId', 'field'],
97
101
  doc: ['schemaVersion'],
98
102
  text: [],
99
103
  hardBreak: [],
@@ -425,6 +429,9 @@ function validateNode(node, path) {
425
429
  }
426
430
  const attributeIssue = validateAttributeNames(node, path)
427
431
  if (attributeIssue) return attributeIssue
432
+ if (node.type === 'liveDataField' || node.type === 'liveDataBlock') {
433
+ return validateLiveDataReference(node) ? null : issue('invalid-live-data', path)
434
+ }
428
435
 
429
436
  if (node.type === 'doc') {
430
437
  if (node.attrs?.schemaVersion !== MESSAGE_DOCUMENT_SCHEMA_VERSION) {
@@ -799,6 +806,10 @@ function renderImage(attrs, className = '') {
799
806
  }
800
807
 
801
808
  function renderStandardNode(node, context, path, accordionItemId) {
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}>`
812
+ }
802
813
  if (node.type === 'text') return renderMarkedText(node, context, path)
803
814
  if (node.type === 'hardBreak') return '<br>'
804
815
  if (node.type === 'paragraph') return `<p>${renderChildren(node, context, path, accordionItemId)}</p>`
@@ -146,6 +146,17 @@
146
146
  aria-label="Handbook topics"
147
147
  >
148
148
  <slot name="sidebar-status" />
149
+ <p
150
+ v-if="liveDataError && !liveDataManaged"
151
+ role="status"
152
+ >
153
+ {{ liveDataError }} <button
154
+ type="button"
155
+ @click="refreshLiveMessageData"
156
+ >
157
+ Retry
158
+ </button>
159
+ </p>
149
160
  <button
150
161
  class="asd20-template-handbook__index-toggle"
151
162
  type="button"
@@ -287,6 +298,7 @@ import { installDocumentPrintLifecycle } from '../../../helpers/documentPrint'
287
298
  import { shouldHandleClientRoutingEvent } from '../../../helpers/linkPolicy'
288
299
  import pageTemplateMixin from '../../../mixins/pageTemplateMixin'
289
300
  import { publicationHeadingPlanKey } from '../../atoms/Asd20RichBodyContent/headingContext'
301
+ import { liveMessageDataMixin, liveMessageDataProviderKey } from '../../../helpers/liveMessageDataRuntime'
290
302
 
291
303
  export default {
292
304
  name: 'Asd20HandbookTemplate',
@@ -303,11 +315,15 @@ export default {
303
315
  Asd20PrintHeader,
304
316
  Asd20PrintContents,
305
317
  },
306
- mixins: [pageTemplateMixin],
318
+ mixins: [pageTemplateMixin, liveMessageDataMixin],
307
319
  provide() {
308
- return { [publicationHeadingPlanKey]: () => this.publicationHeadings.idPlan }
320
+ return {
321
+ [publicationHeadingPlanKey]: () => this.publicationHeadings.idPlan,
322
+ [liveMessageDataProviderKey]: () => ({ snapshot: this.liveDataSnapshot }),
323
+ }
309
324
  },
310
325
  props: {
326
+ liveDataManaged: { type: Boolean, default: false },
311
327
  documentPrint: { type: Boolean, default: false },
312
328
  contentLanguageCode: { type: String, default: '' },
313
329
  contentDirection: { type: String, default: 'ltr' },
@@ -325,9 +341,11 @@ export default {
325
341
  fragmentEpoch: 0,
326
342
  }),
327
343
  computed: {
344
+ liveDataDocuments() { return this.messages.map(message => message.bodyJson || '') },
345
+ liveDataMessages() { return this.messages.map(message => ({ ...message, bodyJson: this.projectLiveMessageData(message.bodyJson) })) },
328
346
  handbookDocument() {
329
347
  return buildHandbookDocument({
330
- messages: this.messages, metaDescription: this.metaDescription,
348
+ messages: this.liveDataMessages, metaDescription: this.metaDescription,
331
349
  documentId: this.publicationDocumentId, documentUrl: this.handbookDocumentUrl,
332
350
  reservedIds: this.publicationReservedIds,
333
351
  })
@@ -0,0 +1,164 @@
1
+ // Generated from CommCenter packages/message-document/liveData.js.
2
+ /* global globalThis */
3
+
4
+ // Canonical public live-data contract. The shared UI's copy is generated by
5
+ // scripts/sync-live-message-data.mjs; never add arbitrary paths or queries here.
6
+ const LIVE_DATA_VERSION = 1;
7
+ const LIVE_DATA_FIELDS = Object.freeze({
8
+ 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",
14
+ }),
15
+ team: Object.freeze({
16
+ contactPhone: "Team contact phone",
17
+ officeHours: "Team office hours",
18
+ staff: "Team staff list",
19
+ }),
20
+ });
21
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+ const LIVE_DATA_NODE_TYPES = new Set(["liveDataField", "liveDataBlock"]);
23
+
24
+ function validateLiveDataReference(node) {
25
+ const attrs = node?.attrs;
26
+ if (!LIVE_DATA_NODE_TYPES.has(node?.type) || !attrs ||
27
+ Object.keys(attrs).some(key => !["source", "sourceId", "field"].includes(key)) ||
28
+ typeof attrs.source !== "string" || typeof attrs.field !== "string" ||
29
+ !Object.hasOwn(LIVE_DATA_FIELDS, attrs.source) ||
30
+ !Object.hasOwn(LIVE_DATA_FIELDS[attrs.source], attrs.field) ||
31
+ typeof attrs.sourceId !== "string" || !UUID.test(attrs.sourceId) || node.content?.length || node.marks?.length) {
32
+ return false;
33
+ }
34
+ const block = ["staff", "administrators"].includes(attrs.field);
35
+ return node.type === (block ? "liveDataBlock" : "liveDataField");
36
+ }
37
+
38
+ function liveDataLabel(attrs) {
39
+ return LIVE_DATA_FIELDS[attrs?.source]?.[attrs?.field] || "Live information";
40
+ }
41
+
42
+ function liveDataKey(attrs) {
43
+ return `${attrs.source}:${attrs.sourceId.toLowerCase()}:${attrs.field}`;
44
+ }
45
+
46
+ function collectLiveDataReferences(bodyJson) {
47
+ let value = bodyJson;
48
+ if (typeof bodyJson === "string") {
49
+ try { value = JSON.parse(bodyJson || "null"); }
50
+ catch { return []; } // Leave legacy/invalid JSON to the existing dual reader.
51
+ }
52
+ if (value?.type !== "doc") return [];
53
+ const references = new Map();
54
+ let count = 0;
55
+ function visit(node, depth = 0) {
56
+ if (++count > 50000 || depth > 100) throw new Error("Message data exceeds document limits.");
57
+ if (!node || typeof node !== "object") return;
58
+ if (LIVE_DATA_NODE_TYPES.has(node.type)) {
59
+ if (!validateLiveDataReference(node)) throw new Error("Invalid live data reference.");
60
+ references.set(liveDataKey(node.attrs), { ...node.attrs });
61
+ }
62
+ if (Array.isArray(node.content)) node.content.forEach(child => visit(child, depth + 1));
63
+ }
64
+ visit(value);
65
+ if (references.size > 100) throw new Error("Too many live data references.");
66
+ return [...references.values()];
67
+ }
68
+
69
+ const dataText = value => typeof value === "string" ? value.trim().slice(0, 12000) : "";
70
+ const textNode = text => ({ type: "text", text });
71
+ const paragraph = text => ({ type: "paragraph", content: [textNode(text)] });
72
+
73
+ function liveDataContent(attrs, snapshot) {
74
+ const collection = attrs.source === "organization" ? snapshot?.organizations : snapshot?.teams;
75
+ const source = collection?.find(item => item.id?.toLowerCase() === attrs.sourceId.toLowerCase());
76
+ if (!source || (attrs.source === "organization" && source.status !== "Published") ||
77
+ (attrs.source === "team" && (source.isPublic !== true || source.ownerOrganization?.status !== "Published"))) return null;
78
+ if (attrs.field === "address") {
79
+ const address = source.address || {};
80
+ const value = [dataText(address.street1),
81
+ [dataText(address.city), dataText(address.state)].filter(Boolean).join(", "),
82
+ dataText(address.zip)].filter(Boolean).join(" · ");
83
+ return value ? [textNode(value)] : null;
84
+ }
85
+ 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);
98
+ return lines.length ? [{ type: "bulletList", content: lines.map(line => ({ type: "listItem", content: [paragraph(line)] })) }] : null;
99
+ }
100
+ const value = dataText(source[attrs.field]);
101
+ return value ? [textNode(value)] : null;
102
+ }
103
+
104
+ // Materialization returns a copy containing only ordinary document nodes.
105
+ // Persist the authored references, never this presentation projection.
106
+ function resolveLiveDataDocument(bodyJson, snapshot, { strict = true } = {}) {
107
+ collectLiveDataReferences(bodyJson);
108
+ const value = typeof bodyJson === "string" ? JSON.parse(bodyJson || "null") : bodyJson;
109
+ const diagnostics = [];
110
+ function resolve(node) {
111
+ if (LIVE_DATA_NODE_TYPES.has(node?.type)) {
112
+ const content = liveDataContent(node.attrs, snapshot);
113
+ if (content) return content;
114
+ diagnostics.push({ code: "live-data-unavailable", reference: liveDataKey(node.attrs) });
115
+ const message = `${liveDataLabel(node.attrs)} is currently unavailable.`;
116
+ return node.type === "liveDataBlock" ? [paragraph(message)] : [textNode(message)];
117
+ }
118
+ if (!node || typeof node !== "object") return [node];
119
+ return [{ ...node, ...(node.attrs ? { attrs: { ...node.attrs } } : {}),
120
+ ...(Array.isArray(node.content) ? { content: node.content.flatMap(resolve) } : {}) }];
121
+ }
122
+ const document = resolve(value)[0];
123
+ if (strict && diagnostics.length) throw new Error("Required live information is unavailable. Review the message data sources.");
124
+ return { document, diagnostics };
125
+ }
126
+
127
+ // One anonymous query captures every dependency for a document batch. No admin
128
+ // secret, user session, arbitrary field names, or private employee data is used.
129
+ async function fetchLiveDataSnapshot(documents, { endpoint, fetchImpl = globalThis.fetch, signal } = {}) {
130
+ const references = new Map();
131
+ 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();
134
+ if (organizationIds.length + teamIds.length > 50 || references.size > 100) throw new Error("Too many live data sources.");
135
+ if (!references.size) return { version: LIVE_DATA_VERSION, organizations: [], teams: [] };
136
+ if (!endpoint) throw new Error("Public message data is not configured.");
137
+ const response = await fetchImpl(endpoint, {
138
+ method: "POST", credentials: "omit", redirect: "error", cache: "no-store", signal,
139
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-cache" },
140
+ body: JSON.stringify({
141
+ query: `query liveMessageData($organizationIds: [uuid!]!, $teamIds: [uuid!]!) {
142
+ organizations(where: {id: {_in: $organizationIds}, status: {_eq: "Published"}}) {
143
+ id status title address phone hours administrators
144
+ }
145
+ teams: groups(where: {id: {_in: $teamIds}, isPublic: {_eq: true}, ownerOrganization: {status: {_eq: "Published"}}}) {
146
+ id title isPublic contactPhone officeHours ownerOrganization { status }
147
+ people(where: {person: {isPublic: {_eq: true}}}, order_by: {person: {id: asc}}, limit: 101) {
148
+ person { id displayName firstName lastName title phone isPublic }
149
+ }
150
+ }
151
+ }`, variables: { organizationIds, teamIds },
152
+ }),
153
+ });
154
+ if (!response.ok) throw new Error("Public message data is unavailable.");
155
+ 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 };
158
+ }
159
+
160
+ export {
161
+ LIVE_DATA_VERSION, LIVE_DATA_FIELDS, LIVE_DATA_NODE_TYPES,
162
+ validateLiveDataReference, liveDataLabel, liveDataKey,
163
+ collectLiveDataReferences, resolveLiveDataDocument, fetchLiveDataSnapshot,
164
+ };
@@ -0,0 +1,57 @@
1
+ import { collectLiveDataReferences, fetchLiveDataSnapshot, resolveLiveDataDocument } from './liveMessageData'
2
+ import { resolveRuntimeConfigValue } from './runtimeConfig'
3
+
4
+ export const liveMessageDataEndpointKey = 'liveMessageDataEndpoint'
5
+ export const liveMessageDataProviderKey = 'liveMessageDataProvider'
6
+
7
+ // The host supplies liveDataDocuments and may provide a batch snapshot to its
8
+ // descendants. Only references trigger a request; ordinary messages are inert.
9
+ export const liveMessageDataMixin = {
10
+ inject: {
11
+ liveDataEndpoint: { from: liveMessageDataEndpointKey, default: '' },
12
+ liveDataProvider: { from: liveMessageDataProviderKey, default: null },
13
+ },
14
+ data: () => ({ liveDataSnapshot: null, liveDataLoading: false, liveDataError: '', liveDataEpoch: 0, liveDataMounted: false, liveDataController: null }),
15
+ computed: {
16
+ liveDataReferencesKey() {
17
+ try { return JSON.stringify(this.liveDataDocuments.flatMap(doc => collectLiveDataReferences(doc))) }
18
+ catch { return '' }
19
+ },
20
+ effectiveLiveDataSnapshot() { return this.liveDataProvider ? this.liveDataProvider().snapshot : this.liveDataSnapshot },
21
+ },
22
+ watch: { liveDataReferencesKey() { if (this.liveDataMounted) this.refreshLiveMessageData() } },
23
+ mounted() { this.liveDataMounted = true; this.refreshLiveMessageData() },
24
+ beforeUnmount() { this.liveDataMounted = false; this.liveDataEpoch++; this.liveDataController?.abort() },
25
+ methods: {
26
+ async refreshLiveMessageData() {
27
+ const epoch = ++this.liveDataEpoch
28
+ this.liveDataController?.abort()
29
+ this.liveDataSnapshot = null
30
+ this.liveDataError = ''
31
+ this.liveDataLoading = false
32
+ if (this.liveDataProvider || this.liveDataManaged || !this.liveDataReferencesKey || this.liveDataReferencesKey === '[]') return
33
+ const controller = new AbortController()
34
+ this.liveDataController = controller
35
+ const timeout = setTimeout(() => controller.abort(), 15000)
36
+ this.liveDataLoading = true
37
+ try {
38
+ const snapshot = await fetchLiveDataSnapshot(this.liveDataDocuments, {
39
+ endpoint: this.liveDataEndpoint || resolveRuntimeConfigValue({ runtimeConfig: this.$config, runtimeConfigKeys: ['graphqlEndpoint'] }),
40
+ signal: controller.signal,
41
+ })
42
+ // Validate availability before accepting a partial capture.
43
+ this.liveDataDocuments.forEach(doc => {
44
+ if (collectLiveDataReferences(doc).length) resolveLiveDataDocument(doc, snapshot)
45
+ })
46
+ if (epoch === this.liveDataEpoch) this.liveDataSnapshot = snapshot
47
+ } catch { if (epoch === this.liveDataEpoch) this.liveDataError = 'Live information is currently unavailable.' }
48
+ finally { clearTimeout(timeout); if (epoch === this.liveDataEpoch) this.liveDataLoading = false }
49
+ },
50
+ projectLiveMessageData(bodyJson) {
51
+ try {
52
+ if (!collectLiveDataReferences(bodyJson).length) return bodyJson
53
+ return resolveLiveDataDocument(bodyJson, this.effectiveLiveDataSnapshot, { strict: false }).document
54
+ } catch { return bodyJson }
55
+ },
56
+ },
57
+ }