Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions client/mass/charts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
3 changes: 3 additions & 0 deletions client/plots/importPlot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
191 changes: 191 additions & 0 deletions client/plots/profile/forms3.ts
Original file line number Diff line number Diff line change
@@ -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
<Component>__<Module>__<Domain> 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
(<Component>__<Module>__<Domain>). 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<string, string[]>): ModuleGroup[] {
const order: string[] = []
const byModule = new Map<string, Map<string, DomainEntry>>()

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<string, DomainEntry>()
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<typeof holder.append> }[] = []
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))
}
}
}
60 changes: 60 additions & 0 deletions client/plots/profile/formsChartTypes.ts
Original file line number Diff line number Diff line change
@@ -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<string>, 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<Map<string, string[]>> {
/*
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<string>()
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)
}
88 changes: 88 additions & 0 deletions client/plots/test/forms3.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Loading