diff --git a/client/mass/charts.js b/client/mass/charts.js index 5b1746ac56..d1ccd2ed59 100644 --- a/client/mass/charts.js +++ b/client/mass/charts.js @@ -202,6 +202,12 @@ function getChartTypeList(self, state) { usecase: { target: 'profileForms2', detail: 'tw' }, config: { chartType: 'profileForms2' } }, + { + // Picker only — its menu renders a Module → Domain → chart-type matrix/list and creates the chosen plot. + label: 'Templates 3', + chartType: 'profileForms3', + clickTo: self.loadChartSpecificMenu + }, ////////////////////// PROFILE PLOTS END ////////////////////// // rest are general plots applicable to all ds { diff --git a/client/plots/importPlot.js b/client/plots/importPlot.js index 569f0627ba..6ec109769a 100644 --- a/client/plots/importPlot.js +++ b/client/plots/importPlot.js @@ -63,6 +63,9 @@ export async function importPlot(chartType, notFoundMessage = '') { case 'profileForms2': return await import('./profile/forms2.ts') + case 'profileForms3': + return await import('./profile/forms3.ts') + case 'profilePlot': return await import('./profile/profilePlot.ts') diff --git a/client/plots/profile/forms3.ts b/client/plots/profile/forms3.ts new file mode 100644 index 0000000000..5b021cacdd --- /dev/null +++ b/client/plots/profile/forms3.ts @@ -0,0 +1,191 @@ +/* +Templates 3 chart-button menu: a Module → Domain → Chart-type matrix/list view. + +Unlike Templates 2 (chart-type tabs over a termdb tree), this surfaces every +template-bearing domain grouped under its PrOFILE module, with each available +chart type shown as a labelled button next to the domain. A domain may expose +multiple chart types, so users can see at a glance what exists and open it in one click. + +Config is read on the client from termdbConfig.plotConfigByCohort[cohort].profileForms2: + - domains[] forms3 reads only each entry's `id` (the template-bearing domains to surface). + The same entries carry `plotTypes` for Templates v1/v2, which forms3 ignores. + - options[] { name, subtype } the subtype → chart-type-name map (profileFormsOptions) + +Each domain's chart types are derived from the database via getChartTypesByDomain (the child terms' +subtypes), NOT from the config's plotTypes — so this picker can never drift from the data and a +domain becomes multi-chart-type automatically when its children span two subtypes. (When Templates 3 +replaces v1/v2, plotTypes can be dropped from the dataset config.) + +Module + domain display names are derived from the domain id, which the build encodes as + ____ e.g. 'FContext__National Context__Care Access and Utilization' +so no extra metadata is needed. Module accent color comes from termdbConfig.colorMap[module]. + +Forms buttons dispatch a profileForms2 plot pre-selected to that chart type (config.activeTab). +Invoked by mass/charts.js loadChartSpecificMenu via the standard chart-specific menu pattern. +*/ + +import { getChartTypesByDomain, type FormsOption } from './formsChartTypes' + +export type FormsDomain = { id: string } +export type Forms2Config = { + options?: FormsOption[] + domains?: FormsDomain[] +} + +export type DomainEntry = { + id: string + name: string + plotTypes: string[] + searchText: string +} +export type ModuleGroup = { module: string; domains: DomainEntry[] } + +/* +Pure model builder: group template-bearing domains under their PrOFILE module, preserving +first-seen order from the config. Module/domain display names come from the domain id +(____). Each domain's chart-type buttons come from chartTypesByDomain +(derived from the DB by getChartTypesByDomain, already in canonical options order). Exported +separately from the DOM rendering and the DB fetch so the grouping is unit-testable with a plain map. +*/ +export function buildTemplates3Model(cfg: Forms2Config, chartTypesByDomain: Map): ModuleGroup[] { + const order: string[] = [] + const byModule = new Map>() + + const ensure = (id: string): DomainEntry => { + const parts = id.split('__') + const moduleName = parts[1] || 'Other' + const domainName = parts.slice(2).join(' / ') || id + let domains = byModule.get(moduleName) + if (!domains) { + domains = new Map() + byModule.set(moduleName, domains) + order.push(moduleName) + } + let entry = domains.get(id) + if (!entry) { + entry = { + id, + name: domainName, + plotTypes: [], + searchText: `${moduleName} ${domainName}`.toLowerCase() + } + domains.set(id, entry) + } + return entry + } + + for (const d of cfg.domains || []) { + const entry = ensure(d.id) + entry.plotTypes = chartTypesByDomain.get(d.id) || [] + } + + return order.map(module => ({ module, domains: [...byModule.get(module)!.values()] })) +} + +export async function makeChartBtnMenu(holder, chartsInstance) { + const termdbConfig = chartsInstance.app.vocabApi.termdbConfig + const cohortKey = termdbConfig?.selectCohort?.values?.[chartsInstance.state.activeCohort]?.keys?.[0] + const cfg: Forms2Config = termdbConfig?.plotConfigByCohort?.[cohortKey]?.profileForms2 || {} + // Derive each domain's chart types from the DB (child subtypes), then group for display. + const chartTypesByDomain = await getChartTypesByDomain( + chartsInstance.app.vocabApi, + (cfg.domains || []).map(d => d.id), + cfg.options || [] + ) + const groups = buildTemplates3Model(cfg, chartTypesByDomain) + /* + Source of module colors: colorMap is built at termdb-build time from the dataset's + hand-maintained module color table (utils/sjglobal-profile/raw/module_hex_codes → + termdbConfig.colorMap). It is keyed by module name and, under each module, by Likert response + CATEGORY (e.g. 'ALMOST ALWAYS') → hex — there is no standalone "module color" field. The matrix + header below borrows a module's first category color purely as a visual accent (see accent note). + */ + const colorMap = termdbConfig?.colorMap || {} + + if (!groups.length) { + holder + .append('div') + .style('padding', '15px') + .style('color', '#777') + .style('font-style', 'italic') + .text('No templates are currently available for this cohort.') + return + } + + const dispatch = (config: { [key: string]: unknown }) => { + chartsInstance.dom.tip.hide() + chartsInstance.app.dispatch({ type: 'plot_create', id: (+new Date()).toString(16), config }) + } + // Data-backed: forms charts open a profileForms2 plot whose questions exist in the db. + const openForms = (domainId: string, plotType: string) => + dispatch({ + chartType: 'profileForms2', + activeCohort: chartsInstance.state.activeCohort, + tw: { term: { id: domainId } }, + activeTab: plotType + }) + + const container = holder.append('div').attr('class', 'sjpp-templates3').style('max-width', '520px') + + // Search box: filter domain rows by module/domain name as the list scales. + const rows: { searchText: string; row: ReturnType }[] = [] + container + .append('input') + .attr('type', 'search') + .attr('placeholder', 'Search domains') + .style('width', '100%') + .style('box-sizing', 'border-box') + .style('margin', '4px 0 8px') + .style('padding', '4px 8px') + .on('input', function (this: HTMLInputElement) { + const q = this.value.trim().toLowerCase() + for (const { searchText, row } of rows) row.style('display', !q || searchText.includes(q) ? '' : 'none') + }) + + const listDiv = container.append('div').style('max-height', '60vh').style('overflow-y', 'auto') + + const makeBtn = (parent, label: string, onClick: () => void) => + parent + .append('div') + .attr('class', 'sja_filter_tag_btn') + .style('display', 'inline-block') + .style('margin', '0 4px 4px 0') + .style('padding', '2px 9px') + .style('font-size', '.85em') + .style('border-radius', '6px') + .style('background-color', '#cfe2f3') + .style('color', 'black') + .style('cursor', 'pointer') + .text(label) + .on('click', onClick) + + for (const { module, domains } of groups) { + /* + Module accent color: colorMap has no single per-module color (it stores per-Likert-category + hexes, see the colorMap note above), so we take the module's FIRST category color as a + presentational accent only. Falls back to neutral gray when a module has no colorMap entry. + */ + const accent = (Object.values(colorMap[module] || {})[0] as string) || '#999' + listDiv + .append('div') + .style('margin', '10px 0 4px') + .style('padding', '3px 8px') + .style('border-left', `4px solid ${accent}`) + .style('font-weight', 'bold') + .text(module) + + for (const entry of domains) { + const row = listDiv + .append('div') + .style('display', 'flex') + .style('flex-wrap', 'wrap') + .style('align-items', 'baseline') + .style('gap', '6px') + .style('padding', '2px 8px 2px 14px') + rows.push({ searchText: entry.searchText, row }) + row.append('div').style('min-width', '180px').style('font-size', '.9em').text(entry.name) + const btns = row.append('div') + for (const plotType of entry.plotTypes) makeBtn(btns, plotType, () => openForms(entry.id, plotType)) + } + } +} diff --git a/client/plots/profile/formsChartTypes.ts b/client/plots/profile/formsChartTypes.ts new file mode 100644 index 0000000000..c347ba6f74 --- /dev/null +++ b/client/plots/profile/formsChartTypes.ts @@ -0,0 +1,60 @@ +/* +Derive a Templates domain's chart types from the database instead of a hand-listed config. + +A domain's chart types are an emergent property of its multivalue children: each child question +term carries a `subtype` (e.g. 'YN Graph', 'Likert Graph') in the termdb, and a domain "offers" a +chart type when at least one child has the matching subtype. This is the same mechanism the +profileForms2 plot uses in init() to build its tabs; centralizing it here lets both Templates +pickers (forms2 tabs, forms3 matrix/list) read chart types from the DB so they can never drift from +the data — and a domain becomes multi-chart-type automatically once its children span two subtypes. + +`subtype → display name` comes from profileFormsOptions (termdbConfig …profileForms2.options), +which also defines the canonical chart-type order. +*/ + +export type FormsOption = { name: string; subtype: string } + +/* +Pure mapper: given the set of child subtypes present under a domain, return the chart-type display +names in canonical options order, including only types that actually have a child. +*/ +export function subtypesToChartTypes(present: Set, options: FormsOption[]): string[] { + return options.filter(o => present.has(o.subtype)).map(o => o.name) +} + +/* +For each domain id, load its multivalue children (vocabApi.getMultivalueTWs → cached server-side per +parent_id) and derive its chart-type names. Returns a Map keyed by domain id. Domains with no +subtype-bearing children map to an empty array. +*/ +export async function getChartTypesByDomain( + vocabApi: { getMultivalueTWs: (opts: { parent_id: string }) => Promise<{ term: { subtype?: string } }[]> }, + domainIds: string[], + options: FormsOption[] +): Promise> { + /* + Demo override while the multi-chart-type data isn't in the db yet. Every domain currently has a + single subtype, so there's no way to see how the Templates 3 picker lays out a domain that offers + more than one chart type. To unblock that review, we force these two domains to expose every + option as a button; every other domain still goes through the real db-derived list below + (subtypesToChartTypes). Remove this block once a domain genuinely carries two subtypes. + + Note for whoever clicks through it: this only shapes the picker. The forms2 plot still builds its + tabs from the actual db children, so opening the forced type lands on an empty tab — that's + expected here; we're validating the picker UI, not a populated plot. + */ + const DEMO_MULTITYPE_DOMAINS = new Set([ + 'FContext__National Context__Care Access and Utilization', + 'FWorkforce__Service Integration__Communication' + ]) + const entries = await Promise.all( + domainIds.map(async id => { + const twLst = await vocabApi.getMultivalueTWs({ parent_id: id }) + const present = new Set() + for (const tw of twLst) if (tw.term.subtype) present.add(tw.term.subtype) + const types = DEMO_MULTITYPE_DOMAINS.has(id) ? options.map(o => o.name) : subtypesToChartTypes(present, options) + return [id, types] as const + }) + ) + return new Map(entries) +} diff --git a/client/plots/test/forms3.unit.spec.ts b/client/plots/test/forms3.unit.spec.ts new file mode 100644 index 0000000000..7cd1f26082 --- /dev/null +++ b/client/plots/test/forms3.unit.spec.ts @@ -0,0 +1,88 @@ +import tape from 'tape' +import { buildTemplates3Model } from '../profile/forms3.ts' +import { subtypesToChartTypes } from '../profile/formsChartTypes.ts' + +/* +Tests the two pure pieces behind the DB-driven Templates 3 matrix/list picker: + +subtypesToChartTypes() — maps the child subtypes present under a domain to chart-type names: + - single subtype → one name; both subtypes → both names in canonical options order + - order independent of input; unknown/empty subtype set → [] + +buildTemplates3Model() — groups domains under their module using a derived chartTypesByDomain map: + - groups by the module segment of the id, preserving first-seen order; derives domain display name + - per-domain chart-type buttons come straight from the map + - empty config yields no groups +*/ + +const OPTIONS = [ + { name: 'Yes/No Barchart', subtype: 'YN Graph' }, + { name: 'Likert Scale', subtype: 'Likert Graph' } +] + +tape('subtypesToChartTypes - single subtype maps to its name', test => { + test.deepEqual(subtypesToChartTypes(new Set(['YN Graph']), OPTIONS), ['Yes/No Barchart']) + test.deepEqual(subtypesToChartTypes(new Set(['Likert Graph']), OPTIONS), ['Likert Scale']) + test.end() +}) + +tape('subtypesToChartTypes - both subtypes returned in canonical order regardless of input', test => { + // input Set built Likert-first, but output follows OPTIONS order (Yes/No first) + test.deepEqual(subtypesToChartTypes(new Set(['Likert Graph', 'YN Graph']), OPTIONS), [ + 'Yes/No Barchart', + 'Likert Scale' + ]) + test.end() +}) + +tape('subtypesToChartTypes - unknown or empty subtypes yield empty', test => { + test.deepEqual(subtypesToChartTypes(new Set(), OPTIONS), [], 'empty set => []') + test.deepEqual(subtypesToChartTypes(new Set(['Bogus Graph']), OPTIONS), [], 'unmatched subtype => []') + test.end() +}) + +tape('buildTemplates3Model - groups by module, derives names, preserves order', test => { + const cfg = { + options: OPTIONS, + domains: [ + { id: 'FContext__National Context__Care Access and Utilization' }, + { id: 'FDiagnostics__Diagnostics__General Laboratory' } + ] + } + const types = new Map([ + ['FContext__National Context__Care Access and Utilization', ['Yes/No Barchart']], + ['FDiagnostics__Diagnostics__General Laboratory', ['Likert Scale']] + ]) + const groups = buildTemplates3Model(cfg, types) + test.deepEqual( + groups.map(g => g.module), + ['National Context', 'Diagnostics'], + 'one group per module in first-seen order' + ) + test.deepEqual( + groups[0].domains.map(d => d.name), + ['Care Access and Utilization'], + 'domain display name is the third id segment' + ) + test.deepEqual(groups[0].domains[0].plotTypes, ['Yes/No Barchart'], 'chart types come from the derived map') + test.end() +}) + +tape('buildTemplates3Model - multi chart-type domain carries both from the map', test => { + const cfg = { options: OPTIONS, domains: [{ id: 'FContext__National Context__Care Access and Utilization' }] } + const types = new Map([ + ['FContext__National Context__Care Access and Utilization', ['Yes/No Barchart', 'Likert Scale']] + ]) + const groups = buildTemplates3Model(cfg, types) + test.deepEqual( + groups[0].domains[0].plotTypes, + ['Yes/No Barchart', 'Likert Scale'], + 'both derived chart types surface' + ) + test.end() +}) + +tape('buildTemplates3Model - empty config yields no groups', test => { + test.deepEqual(buildTemplates3Model({}, new Map()), [], 'no domains and no chart groups => empty') + test.end() +})