Hardened in Wave 0 on d3BarChart (branch v3/d3BarChart-standalone). This is
the reproducible procedure for converting one chart bundle into a fully
standalone, GraphQL-only bundle for waves 1–N. It is written from what the bar
conversion actually required, not from what the design predicted. Read the
approved design first: docs/superpowers/specs/2026-07-11-graphql-standalone-design.md.
The end state per chart: the suffixed d3XxxGraphql bundle folder (§1.1) + the
d3 static resource is everything. No c/d3Lib, c/dataService,
c/themeService, c/chartUtils, c/graphqlService imports; no
@salesforce/apex/* imports; no soqlQuery or fetchMode; a new
graphqlQuery free-text admin override.
Suffix amendment (v1.0.0) — folded in: every conversion starts by renaming the chart to its
*Graphqlsuffixed identity (commit 0, §1.1) — folder/classd3XxxGraphql, tagc-d3-xxx-graphql,masterLabelgains(GraphQL)— closed by a rename-completeness grep gate. Suffixed bundles are new components org-side, so the legacy detach → deploy → reattach dance does not apply to conversions (release-step consequences in §7).
- A worktree + branch off
main(do not work in the main checkout when implementers run in parallel). Waves 4–8 shape:git worktree add ../d3-lwc-graphql.worktrees/<chart> -b wave<N>/<chart>-graphql main— then symlinknode_modulesfrom the main checkout into the worktree so jest runs (ln -s ~/code/d3-lwc-graphql/node_modules <worktree>/node_modules). node -v→ v20 (jest ran under Node 26 here fine, but SF CLI needs 20).npx jest --silentgreen from the start (full suite; ~2.4s / 3,377 tests). The suite always runs whole — there is no per-component narrowing flag (--testPathPatterndoes nothing here). To watch just your bundle while iterating, pass a path:npx jest force-app/main/default/lwc/<chart>.- Know your chart's shared-module usage before you start (step 2).
Wave 1 produced three divergent-but-green interpretations of this split; the shape below is the one blessed shape. One mechanical rename commit + two code commits + one docs commit, in this order:
refactor(<oldName>): rename to <newName> per suffix amendment— the §1.1 suffix rename, purely mechanical, BEFORE any conversion work: the bundle keeps its shared-module architecture and the suite stays green. All later commits work under the final*Graphqlidentity, so nothing gets renamed twice.refactor(<chart>): inline shared-module subsets as bundle-local files— purely ADDITIVE: create the bundle-local module files only. No component edits, no import swaps, no test changes. Nothing references the new files yet, so the full suite is trivially green and the diff is reviewable as a single question: "is the inlining a faithful subset of the shared modules?"feat(<chart>): GraphQL-only self-fetch with graphqlQuery override— everything behavioral, riding together: the import swap (componentc/*→./*, test mocksc/d3Lib→../d3Loader; §3), the §4.3 render-orchestration hardening, the TDD GraphQL-only conversion (§4.1 RED → §4.2 GREEN — remove ApexsoqlQuery+fetchMode, addgraphqlQuery), and the.js-meta.xmledit (§5). GREEN at the end.
docs(...)— recipe/CHANGELOG updates as needed.
Rename the chart to its per-line suffixed identity — folder + exported class
d3XxxGraphql, tag c-d3-xxx-graphql, <masterLabel> gains (GraphQL)
(e.g. Bar Chart (GraphQL)), so App Builder/Flow pickers disambiguate it from
the org's unsuffixed legacy instance and from the sibling soql line. Shown for
d3BarChart — substitute your chart:
cd force-app/main/default/lwc
git mv d3BarChart d3BarChartGraphql
cd d3BarChartGraphql
for f in d3BarChart.js d3BarChart.html d3BarChart.css d3BarChart.js-meta.xml; do [ -f "$f" ] && git mv "$f" "${f/d3BarChart/d3BarChartGraphql}"; done
cd __tests__
for f in d3BarChart.*; do git mv "$f" "${f/d3BarChart/d3BarChartGraphql}"; doneThe .css file rides in the loop behind an existence guard — several bundles
ship one, and a rename that leaves it behind orphans the stylesheet (soql-sweep
finding). The ${f/old/new} bash replace is plain substring substitution, not
word-anchored — safe only because you cd into the one bundle first; never run
it from a broader directory.
Then edit:
- Component .js:
export default class D3BarChart→D3BarChartGraphql, plus every bundle-local self-referential name string (ABOUTME lines,console.errorprefixes) — these carry the old name and the completeness gate below never zeroes until they're updated. .js-meta.xml:<masterLabel>gains(GraphQL); nothing else changes in commit 0.- Every test file: module import
c/d3BarChart→c/d3BarChartGraphql;createElement("c-d3-bar-chart"…)→c-d3-bar-chart-graphql; describe/it strings that name the chart updated. Bundle-local module files (d3Loader.js,data.js, etc.) don't exist yet at commit 0 — they arrive already-suffix-neutral in commit 1.
Staging-sanity step (MANDATORY — soql-sweep pilot finding). git mv on a
file that carries unstaged edits stages the OLD committed blob at the new path
while the working tree holds your edit — git status shows it only as a
second-column M on the RM line, easy to miss. After ALL renames and edits:
run git add -A -- force-app from the repo root (NOT bare git add -A), then
confirm git diff (unstaged) is EMPTY and git status --short shows no
node_modules before committing.
Why the pathspec (wave-4 finding, hit or dodged by all five implementers):
.gitignore's node_modules/ trailing-slash pattern matches directories only;
the §0 worktree symlink is a mode-120000 FILE, so it is NOT ignored
(git check-ignore node_modules fails) and bare git add -A stages it into a
public-repo commit. Do NOT "fix" this per-worktree via .git/info/exclude —
in a worktree that file lives in the shared common gitdir, so the exclusion
would leak to the main checkout and every sibling worktree. The pathspec form
is the safe one.
Rename-completeness gate (record both greps in your report):
grep -rhin "<oldName>" force-app/main/default/lwc/<newName>/ | grep -vi "<newName>" # → 0 hits
grep -rhin "<old-kebab-tag>" force-app/main/default/lwc/<newName>/ | grep -vi "<new-kebab-tag>" # → 0 hitsThe -h flag is load-bearing (soql-sweep Wave-C finding): without it every
output line carries the file PATH, which contains <newName>, so the grep -v
filter silently deletes true positives and the gate false-passes. The -i is
equally load-bearing (wave-4 band finding): the exported CLASS form D3XxxChart
differs from the folder form only by case, so a case-sensitive gate is blind to
it — 16 of band's 38 old-name occurrences were the class form. If a hook blocks
grep -r, run the non-recursive equivalent from inside the bundle, preserving
BOTH flags: grep -hin "<oldName>" *.* __tests__/* | grep -vi "<newName>".
Close commit 0 with npx jest force-app/main/default/lwc/<newName> green;
lint-staged runs the related tests on commit.
Why this matters more now: the whole reason conversions no longer fight the property-removal deploy block is that the suffixed bundle is a NEW component. A half-renamed bundle (old tag in a test, old class in an ABOUTME) silently forfeits that guarantee's audit trail — the gate is what proves the new identity is complete.
Why the import swap cannot ride in commit 1. The inlined utils.js omits
createLayoutRetry (§2.2 — it carries the §4.3 render-orchestration defect). The
moment the component switches from c/chartUtils to ./utils, its
createLayoutRetry import and call sites dangle and the suite goes red — until
the §4.3 hardening removes them. So the import swap and the §4.3 hardening are
coupled and must land together in commit 2; there is no way to swap imports in
commit 1 and stay green. Keeping commit 1 additive-only (new files, zero
references) is exactly what makes it independently green — do not try to swap
imports there.
- Open the component and list its
c/*imports. For bar:loadD3(d3Lib);prepareData, aggregateData, OPERATIONS, MAX_RECORDS(dataService);getColors, DEFAULT_THEME(themeService);formatNumber, truncateLabel, createTooltip, createResizeHandler, buildTooltipContent, createLayoutRetry, applySvgA11y(chartUtils);buildAggregateQuery, buildRecordQuery, normalizeAggregate, normalizeRecords(graphqlService). - For each imported symbol, open the shared module and trace its transitive
closure — every helper and module-level constant it calls. Copy the closure,
not just the named export. Bar examples:
prepareDatapulls invalidateData,validateFields,truncateData,MAX_RECORDS.aggregateDatapulls inOPERATIONS.getColorspulls inextendColors,PALETTES,DEFAULT_THEME.buildTooltipContentdefaults its formatter toformatNumber, soformatNumbercomes too.buildAggregateQuery/buildRecordQuerypull inbuildWhere→formatValue,OPERATORS, andAGG_FN.
- Do NOT copy exports the chart never touches. Dropped from the bar inlines:
getD3/resetD3;CHART_LIMITS,sampleData,aggregateSeriesData,computeQuartiles,computeRunningTotal,buildMatrix,buildHierarchy,applyFilterClause;THEMES,createColorScale,getSequentialRamp,SEMANTIC_*;formatCurrency,formatPercent,getContrastColor,parseDate,computeDateExtent,calculateDimensions;buildMultiGroupQuery,normalizeMultiGroup. - Rewrite any inlined comment that is now actively false in a standalone
bundle (CLAUDE.md permits removing only false comments). The dataService and
graphqlService docstrings referenced "prefer server-side getAggregatedData
Apex" and
(use dataSource="apex")— those were rewritten to describe the client-side / record-query reality. Do not add temporal "copied from X" notes; ABOUTMEs must be evergreen ("Bundle-local … for the d3 bundle").
<chart>/
d3Loader.js # loadD3 singleton + CSP fetch/eval fallback
theme.js # PALETTES, DEFAULT_THEME, extendColors, getColors
data.js # MAX_RECORDS, OPERATIONS, validate/truncate/prepare/aggregate
utils.js # formatters, tooltip, resize observer, applySvgA11y
graphql.js # buildWhere/buildRecordQuery/buildAggregateQuery/normalizers
Note: do not inline the shared createLayoutRetry — it carries the
render-orchestration defect (§4.3). The resize observer replaces it.
Every inlined file starts with a 2-line // ABOUTME: header. graphql.js (a
bundle-local module) coexists with the platform lightning/graphql import — the
compiler distinguishes ./graphql (relative) from lightning/graphql (bare) so
there is no collision; jest's moduleNameMapper for ^lightning/graphql$ does
not match the relative path either.
Bar inlined line counts: d3Loader 79, theme 99, data 173, utils 229, graphql 141.
Pin this adapted normalizeRecordsGeneric in every bundle's graphql.js. It
extends the shared version with object-key auto-detection (so a free-text
query targeting any object is accepted) and a project-all fallback (project
every node field when fields is omitted). Both are needed for the admin
override; the shared version required an explicit object + field list.
export function normalizeRecordsGeneric(data, { objectApiName, fields } = {}) {
const queryRoot = data?.uiapi?.query;
if (!queryRoot) return [];
const key =
objectApiName && queryRoot[objectApiName]
? objectApiName
: Object.keys(queryRoot)[0];
if (!key) return [];
const edges = queryRoot[key]?.edges ?? [];
return edges.map((e) => {
const node = e.node ?? {};
const record = {};
(fields ?? Object.keys(node)).forEach((f) => {
record[f] = node[f]?.value ?? null;
});
return record;
});
}This is the first move inside commit 2, and it rides with the §4.3 hardening —
the two are coupled (§1). Do not expect an independently-green checkpoint
after the bare import swap: once the component imports from ./utils, its dangling
createLayoutRetry reference reddens the suite until §4.3 removes it. Green is
confirmed only after §4.3 has landed.
- Component:
c/d3Lib→./d3Loader,c/dataService→./data, etc. - The one non-obvious move: the tests mock the loader. Change
jest.mock("c/d3Lib", …)+import { loadD3 } from "c/d3Lib"tojest.mock("../d3Loader", …)+import { loadD3 } from "../d3Loader". Ajest.mockof a bundle-relative path works: jest keys the module registry by resolved absolute filename, so the test's../d3Loaderand the component's./d3Loaderare the same module and the mock applies to both. Verified in Wave 0 (full suite stayed green once the swap + §4.3 rode in). - If the chart uses the gantt-specific
normalizeRecordsand you chose not to inline it, switch its Count path tonormalizeRecordsGenericin this same commit. Output is identical ([{ [groupByField]: value }, …]), so tests stay green.
Run npx jest --silent after §4.3 has landed → must be green before
committing commit 2.
Per tier:
- Delete
@salesforce/apex/*imports +jest.mock(...apex..., { virtual: true })and themockResolvedValuelines inbeforeEach. - Delete every test that asserts on
executeQuery/getAggregatedData/soqlQuerybehavior or setsfetchMode. Grep for leftovers — a lingeringexpect(executeQuery)...in a test you kept becomes aReferenceErroronce the import is gone. Command:grep -nE 'executeQuery|getAggregatedData|soqlQuery|fetchMode|@salesforce/apex' __tests__/*.js - The old "no data source → error" expectation becomes a no-data state (neither error nor chart), because an un-provisioned wire is not an error.
- Add override coverage to the
.graphql.test.jstier: (a) free-textgraphqlQueryused verbatim + aggregated client-side, (b) free-text wire errors surface, (c) a blank/whitespacegraphqlQueryfalls through to the structured builder, (d)recordCollectionbeats a setgraphqlQuery. All four live in the.graphql.test.jstier (settled in wave 4 — the bar reference predates this rule and keeps case (d) in its unit tier; historical variance, do not copy). - Precondition — confirm the chart ships
.e2e/.integrationtiers. The happy-path conversion below has nowhere obvious to live in a chart that only ships two tiers (.test.js+.graphql.test.js). If the base bundle lacks the.e2e/.integrationtiers, do NOT silently skip this step — instead place the converted happy-path wire test in the.graphql.test.jstier (so a real self-fetch scenario still runs end to end), AND flag the absent.e2e/.integrationtiers in your DONE report as a backfill/waiver item. (Wave 1 hit this ond3StackedBarChart(task #38); Wave 2 ond3LineChart/d3AreaChart/d3SparklineGrid(task #47) — all ship only.test.js+.graphql.test.js. Their e2e/integration happy-path conversion was deferred to an explicit backfill task rather than dropped.) - Convert one happy-path fetch test in
.e2e/.integrationfrom the old Apex/SOQL path to agraphql.emit(...)wire path so a real self-fetch scenario still runs end to end. - Place that converted happy-path wire test in a happy-path/lifecycle
describeblock, never wherever the donor Apex/SOQL test happened to sit. In Wave 0 the old SOQL test lived underdescribe("error recovery"); a successful self-fetch does not belong there. Describe-block accuracy is mandatory — hygiene check 4 (test name ↔ behavior) extends to the block name.
Run npx jest force-app/main/default/lwc/<chart> → confirm RED is exactly the
structured-wire tests (now un-gated) + the new override tests. If anything else
is red, it is test cruft, not the feature — fix the test.
Remove the Apex imports and the soqlQuery/fetchMode/filterClause @api
properties. filterClause was only read by the removed Apex path — dropping it
is a dead-surface removal (hygiene check 3), not a feature cut. Add:
/**
* Free-text UI API GraphQL document. When non-blank it overrides the
* structured query builder as the wire's data source; the returned records
* are aggregated client-side by groupByField/valueField/operation.
*/
@api graphqlQuery = "";
get hasFreeTextQuery() {
return !!(this.graphqlQuery && this.graphqlQuery.trim());
}gqlQuery getter — recordCollection wins, then free-text, then structured:
get gqlQuery() {
// recordCollection wins: skip the wire so it is never the data source.
if (this.recordCollection && this.recordCollection.length > 0) {
return undefined;
}
// Admin free-text override: pass the document straight to the wire.
if (this.hasFreeTextQuery) {
return gql`
${this.graphqlQuery}
`;
}
// Structured builder path.
if (!this.objectApiName || !this.groupByField || !this.operation) {
return undefined;
}
if (this.operation !== OPERATIONS.COUNT && !this.valueField) {
return undefined;
}
let queryString;
try {
queryString =
this.operation === OPERATIONS.COUNT
? buildRecordQuery({ objectApiName: this.objectApiName,
fields: [this.groupByField], filter: this.graphqlFilter,
first: this.recordLimit || 2000 })
: buildAggregateQuery({ objectApiName: this.objectApiName,
groupByField: this.groupByField, valueField: this.valueField,
operation: this.operation, filter: this.graphqlFilter,
first: this.recordLimit || 2000 });
} catch {
return undefined; // leave the wire un-provisioned; error surfaces below
}
return gql`
${queryString}
`;
}Wire handler — drop the fetchMode gate; guard recordCollection; branch
free-text → generic-normalize + client-aggregate, else the chart's normal path:
@wire(graphql, { query: "$gqlQuery" })
wiredAggregate({ data, errors }) {
if (this.recordCollection && this.recordCollection.length > 0) return;
if (errors) { this.error = this._formatGqlErrors(errors); this.isLoading = false; return; }
if (!data) return; // initial undefined emission
try {
let normalized;
if (this.hasFreeTextQuery) {
const fields = this.operation === OPERATIONS.COUNT
? [this.groupByField]
: [this.groupByField, this.valueField];
const records = normalizeRecordsGeneric(data, { objectApiName: this.objectApiName, fields });
if (!records.length) {
// Hint the record-query contract, not the generic "empty" message.
this.error = "The GraphQL Query returned no records. It must be a UI API record query (uiapi.query).";
this.isLoading = false; return;
}
normalized = this._aggregateRawData(records);
} else if (this.operation === OPERATIONS.COUNT) {
const records = normalizeRecordsGeneric(data, { objectApiName: this.objectApiName, fields: [this.groupByField] });
normalized = this._aggregateRawData(records);
} else {
normalized = normalizeAggregate(data, { objectApiName: this.objectApiName,
groupByField: this.groupByField, valueField: this.valueField, operation: this.operation });
}
if (!normalized.length) this.error = "No data after aggregation";
else { this.chartData = normalized; this.error = null; this.chartRendered = false; }
} catch (e) { this.error = e.message; }
this.isLoading = false;
}loadData collapses to recordCollection-only (avoid a trailing return; —
ESLint no-useless-return fails the lint-staged hook):
async loadData() {
if (this.recordCollection && this.recordCollection.length > 0) {
this.chartData = this._aggregateRawData([...this.recordCollection]);
}
}Loading state — every conversion must carry this. The old
finally { this.isLoading = false } clears the spinner before the wire emits,
producing a no-data flash on the self-fetch path. Gate it on whether a wire is
provisioned and still pending:
} finally {
// Keep the spinner up while a GraphQL query is provisioned but has not yet
// emitted data or an error; the wire handler clears isLoading on arrival.
if (this.hasData || this.error || !this.gqlQuery) {
this.isLoading = false;
}
}TDD it: with a provisioned wire and no emission yet, the spinner shows (no chart,
no error); the first graphql.emit(...) clears it. When no wire is provisioned
(recordCollection resolved it, or nothing is configured) the spinner clears
immediately — the no-data state is then correct, not a flash.
The inlined render loop must not reproduce the shared createLayoutRetry
silent give-up. Live on AGENT, a cold-cache/wedged boot rendered an empty
shell (slds-card + chart-container + tooltip element, but no svg, no spinner,
no error). Two silent failure modes cause it:
- rAF give-up at width 0.
createLayoutRetrypolls the container width formaxAttempts=60frames then returns with no signal (and rAF is throttled on hidden/busy pages). Past the budget the chart never renders — and in the originalinitializeChartthe ResizeObserver was never even installed at width 0, so a later growth is never caught. - Sub-margin
renderChartbail after the tooltip.initializeChart's gate waswidth === 0, so a transient width smaller than the chart's own horizontal margins passes the gate, creates the tooltip, thenrenderChartcomputeswidth = containerWidth - (left + right margin)≤ 0 and returns before appending the svg — whileinitializeChartreturnstrue, latchingchartRendered. Tooltip present, no svg, never retried. (This is the exact state observed live.) The trigger width is chart-specific: bar's margins sum to ~80px (a 40px container fires it),d3HorizontalBarChart's areleft 160 + right 30 = 190px, and the time-series line-family (line/area/step/variableColorLine) all useleft 60 + right 30 = 90px. sparklineGrid is different in kind — its bail threshold is grid chrome, not a plotting margin:labelWidth 120 + valueWidth 80 + 40 = 240px. Compute the threshold from the chart in front of you, not from bar's numbers.
Fix — bundle-local utils.js + component; do NOT touch shared chartUtils:
- Delete the inlined
createLayoutRetry. Install ONEcreateResizeHandlerobserver per container generation ininitializeChart, which draws on the first measurable width and re-draws on every resize — no give-up window. The guard is container identity, not mere existence: the template's if/elseif chain destroys.chart-containeron every pass through loading/error/no-data, so a bareif (!this.resizeHandler)guard strands the tooltip in a detached node and leaves the observer watching a dead element after an error→recovery cycle — silently dead tooltips, dead resize responsiveness, and a recovery container that boots unmeasurable never renders (the §4.3 empty shell on the recovery path). Found by three independent wave-4 reviewers (backported to all 16 v1.0.0 bundles in v1.6.0 — every converted bundle now carries the rebind guard). Trackthis._observedContainer; when the container changes,cleanup()(which disconnects the observer and nulls both refs) then re-create both against the new container. Keep the immediategetBoundingClientRectdraw only as a warm-path fast path.disconnectedCallbackjust callscleanup(); drop the_layoutRetryfield + cancel and thecreateLayoutRetryimport. - Wrap every render in
_safeRenderChart(try/catch) so an exception thrown mid-render surfaces to the component error state (same UX as wire errors), never a silent partial render.
initializeChart() {
const container = this.template.querySelector(".chart-container");
if (!container) return false;
if (this._observedContainer && this._observedContainer !== container) {
// The template destroyed the old container (loading/error/no-data pass):
// rebind, or the tooltip writes into a detached node and the observer
// watches a dead element.
this.cleanup();
}
if (!this.tooltip) this.tooltip = createTooltip(container);
if (!this.resizeHandler) {
this.resizeHandler = createResizeHandler(container, ({ width }) => {
if (width > 0) this._safeRenderChart(width);
});
this.resizeHandler.observe();
}
this._observedContainer = container;
const { width } = container.getBoundingClientRect();
if (width > 0) this._safeRenderChart(width);
return true;
}
_safeRenderChart(containerWidth) {
try {
this.renderChart(containerWidth);
} catch (e) {
this.error = e.message || "Failed to render chart";
this.isLoading = false;
}
}Tests (unit tier): (a) container 0-width, capture the RO callback, fire it with a
measurable width → chart renders (this is the ONLY genuinely-RED case against
old code, which installs no observer at width 0 — the sub-margin case is a
characterization/regression guard, because a hand-fired RO callback drives the
old code too; report it as such, not as RED); (b) a render exception → error
state visible (see §8's mockD3-leak trap — a module-level shared-const
mockD3 used to force the throw must be restored in a finally, or the
mutation reddens later describes); (c) disconnect disconnects the observer; (d)
exactly one observer per container generation; (e) error→recovery rebind:
render with data, drive the wire into an error (the template destroys the
container), emit good data again → tooltip and observer are re-created against
the NEW container (assert tooltip creation ran twice and that firing the newly
captured RO callback renders).
The sub-margin fixture width in (a)/(b) is chart-specific — feed a width
strictly between 0 and the chart's own left + right margin sum (bar ~80px so
40px works; d3HorizontalBarChart needs a width under 190px; the line-family's
is 90px so use 50–80; sparklineGrid bails on grid chrome at 240px, so go
under that). The hazard runs HIGH, not low: a fixture at or above the chart's
margin sum renders successfully and the test silently proves nothing — any
non-zero width under the sum exercises the bail.
Why this matters more now: with recordCollection the parent usually sizes
the container before data arrives; with GraphQL-by-default the chart boots and
self-fetches inside App Builder / Flow where the container is frequently
unmeasurable for many frames — so this pre-existing library defect fires far more
often. Every converted chart needs the fix in its inlined utils.js.
- Remove the
soqlQuery<property>(and its "Data Source" comment) and thefetchMode<property>. - Add the
graphqlQueryproperty. Keep the free-text contract and the structured-Count bound in separate descriptions — they are different concerns:- The structured-Count bound ("Count is bounded to the first Record Limit
rows, default 2000") lives on the
operation(Aggregation) property, since it describes the built Count query, not the free-text one. - The free-text contract lives on
graphqlQuery: it must be a record query (uiapi.query) whose node selects the Group By and Value fields as top-level fields, and it carries the accepted footgun (see §Judgment).
- The structured-Count bound ("Count is bounded to the first Record Limit
rows, default 2000") lives on the
<property name="operation" type="String" label="Aggregation" default="Sum"
datasource="Sum,Count,Average"
description="How to aggregate the values. On the GraphQL self-fetch path, Count is bounded to the first Record Limit rows (default 2000)."/>
<!-- GraphQL Query Override -->
<property name="graphqlQuery" type="String" label="GraphQL Query"
description="Optional override of the built query. Must be a UI API record query (uiapi.query) whose node selects the Group By Field and Value Field as top-level fields; the returned rows are aggregated client-side by <the chart's field mappings>. UI-API-queryable objects only; at most 2,000 records; GraphQL syntax. Footgun: if the Value Field is missing from the query, bars aggregate to zero rather than erroring. Leave blank to build the query automatically."/>- Broaden
objectApiNameonly when the chart has drill-down. It now also drives the self-fetch, so if the label/description said only "Drill-Down Object", widen it (bar became label "Object API Name", description "Object to query. When set (and no records are passed in), the chart self-fetches this object via GraphQL. Also used for drill-down navigation…"). For a chart with no drill-down, just label it "Object API Name" and describe it as the query object — do not imply navigation. - Keep
apiVersionat 65.0 (floor for dynamicgqlstring interpolation). - Set
recordLimit's metamaxto2000. The UI API record query capsfirst:at 2,000; the legacy meta'smax="10000"advertises a ceiling the only remaining fetch path rejects (fixed across all 21 bundles in v1.6.0). recordLimitgoverns the structured path only — the meta must say so. The structured builder passesfirst: this.recordLimit || 2000and the recordCollection path truncates viaprepareData, but the free-textgraphqlQuerypath performs NO truncation — the pasted document's ownfirst:clause is the only bound there (wave-4 finding, confirmed on all five bundles). The legacy description "Maximum records to process" over-promises; the canonical description (v1.6.0, shipped on all 21 bundles) is: "Maximum records to fetch on the structured self-fetch path. A free-text GraphQL Query is bounded only by its ownfirst:clause. Leave empty for default (2000)."- Declare
graphqlFilterin the App Builder targetConfig (NOT FlowScreen — records come from the flow there). The component must accept both the object form and the App Builder JSON string — see §5.2.
<!-- GraphQL Filter -->
<property
name="graphqlFilter"
type="String"
label="GraphQL Filter (JSON)"
description='Optional filter for the structured self-fetch, as JSON: {"field":"Name","operator":"like","value":"[D3DEMO]%"}. Operators: eq, ne, gt, gte, lt, lte, like, in. Ignored when records are passed in or a GraphQL Query override is set.'
/>- Update
<description>only if it names SOQL/Apex (bar's did not).
Add lightning__FlowScreen to <targets> and a second <targetConfig> for
it. Adopt-and-keep, never strip: if the chart already declares
lightningCommunity__Default/lightningCommunity__Page targets (several Wave-2
time-series charts do), preserve them — you are adding FlowScreen, not
replacing the target list (mirrors the meta-merge sync principle, source side).
Flow passes a record collection in, so expose recordCollection (as a
generic sObject collection) plus the render config — not the self-fetch
knobs (graphqlQuery, objectApiName) and not drill-down/limit knobs. The
generic sObject collection uses a <propertyType extends="SObject"> type
parameter with type="{T[]}" (verified against the Salesforce LWC docs; not
deploy-verified in Wave 0 since deploys are out of scope):
<targets>
<target>lightning__AppPage</target>
<target>lightning__RecordPage</target>
<target>lightning__HomePage</target>
<target>lightning__FlowScreen</target>
</targets>
...
<targetConfig targets="lightning__FlowScreen">
<propertyType name="T" extends="SObject" label="Record Type"
description="Generic sObject type of the input record collection"/>
<property name="recordCollection" type="{T[]}" label="Records" role="inputOnly"
description="Collection of records to chart, from the flow"/>
<property name="groupByField" type="String" label="Group By Field"
description="API name of the category field"/>
<property name="valueField" type="String" label="Value Field"
description="API name of the numeric field to aggregate (not required for Count)"/>
<property name="operation" type="String" label="Aggregation"
datasource="Sum,Count,Average" description="How to aggregate the values"/>
<property name="height" type="Integer" label="Height (px)"
description="Chart height in pixels"/>
<property name="theme" type="String" label="Color Theme"
datasource="Salesforce Standard,Warm,Cool,Vibrant"
description="Color palette for the chart"/>
<property name="advancedConfig" type="String" label="Advanced Config (JSON)"
description='{"showGrid": true, "showLegend": false, ...}'/>
</targetConfig>The XML above is bar's list — an EXAMPLE, not a template. Expose the
render-config properties the chart in front of you actually reads, including its
chart-specific knobs: sortBy/sortDirection on d3SortedBarChart,
seriesField on the stacked/matrix charts, and so on. Keep the always-present
ones (field mappings, operation, height, theme, and advancedConfig where
the chart parses it). Omit the self-fetch knobs (graphqlQuery, objectApiName)
and the drill-down/limit knobs unless a chart already treats them as
flow-relevant. jest does not parse the meta, so the Flow config is not covered by
the suite — verify it at deploy time in the release step.
Every converted bundle's structured self-fetch path takes an optional
single-condition filter, { field, operator, value }, fed to the inlined
buildWhere. In App Builder the property is always a JSON string; a
parent/Flow component that sets it programmatically may pass the object
directly. Both forms — plus a blank string meaning "no filter" — must go
through one accessor pair, never a plain @api field:
/**
* Structured filter for the GraphQL path: { field, operator, value }.
* Accepts the object directly (programmatic use) or a JSON string (the
* App Builder property). An unparseable string surfaces the component
* error state and provisions no query, rather than silently querying
* unfiltered.
*/
@api
get graphqlFilter() {
return this._graphqlFilter;
}
set graphqlFilter(value) {
this._graphqlFilterInvalid = false;
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
this._graphqlFilter = undefined;
return;
}
try {
this._graphqlFilter = JSON.parse(trimmed);
} catch {
this._graphqlFilter = undefined;
this._graphqlFilterInvalid = true;
this.error =
'Invalid GraphQL Filter: must be JSON like {"field":"Name","operator":"like","value":"[D3DEMO]%"}';
this.isLoading = false;
}
} else {
this._graphqlFilter = value;
}
}Backing fields go beside _config — in PRIVATE PROPERTIES, not TRACKED
STATE. Every bundle's _config = {} already sets this precedent; put the
filter's backing fields in the same section:
_graphqlFilter;
_graphqlFilterInvalid = false;gqlQuery guard placement. In the gqlQuery getter, insert the guard
after the free-text branch, before the structured requirement checks:
// An unparseable GraphQL Filter must not fall back to an unfiltered query.
if (this._graphqlFilterInvalid) {
return undefined;
}Why this placement: recordCollection and the free-text override both
ignore the filter, and neither combination can carry a string filter —
recordCollection is Flow-only where graphqlFilter is not declared,
free-text explicitly overrides — so the guard protects exactly the
structured path.
Meta note. graphqlFilter is declared in the App Builder targetConfig
only (§5) — never in the lightning__FlowScreen targetConfig, since Flow
passes recordCollection in and never touches the self-fetch filter. A
FlowScreen targetConfig that separately exposes recordLimit for its own
recordCollection-capping path (band, difference carry one) keeps that
property's generic "Maximum records to process. Leave empty for default."
wording — the structured-path-only recordLimit wording in §5 applies to
the App Builder targetConfig only, not this one.
4-test contract (.graphql.test.js, inside
describe("graphqlFilter JSON-string parsing", ...)):
- A JSON-string
graphqlFilterparses into the structuredwhere:clause. - An object
graphqlFilter(not a string) passes through unchanged into the samewhere:clause. - An unparseable JSON string surfaces the component error state and
provisions no query (
graphql.getLastConfig().query, or the lastgql.mock.resultsentry, isundefined). - A blank/whitespace-only string is treated as no filter — the query is
still provisioned, and it carries no
where:clause.
Adapt the structured field-mapping props per chart (bar's
objectApiName/groupByField/valueField/operation; slope's
startValueField/endValueField; band's lowerField/upperField;
difference's primaryField/secondaryField; and so on), but keep all four
cases and the where-clause assertion
(where: { Name: { like: "[D3DEMO]%" } }) uniform — buildWhere is
identical across every bundle's local graphql.js.
Known trap — RED is 2 of 4, not 4 of 4. Tests 2 and 4 above already pass
against the old plain @api graphqlFilter; field, because buildWhere's
duck-typing (if (!filter || !filter.field || !filter.operator) return "")
already no-ops anything without a .field — a real object round-trips and a
blank string has no .field. Only tests 1 and 3 (JSON-string parsing, error
surfacing) exercise genuinely new behavior. Reproduced identically on the
first 6 bundles it was authored against (bar + the wave-4 five) — don't read
a 2-of-4 RED count as a broken test.
Donor here is the pre-conversion chart; the leak is Apex/SOQL/fetchMode
strings. <chart> below means the SUFFIXED bundle name — the §1.1 rename
happened in commit 0, so every filename carries Graphql. Run and report all
four:
# 1. Stale donor strings (source + tests). Expect zero, or an intentional survivor.
cd force-app/main/default/lwc/<chart>
grep -niE 'soql|apex|fetchmode|executequery|getaggregateddata|filterclause|D3ChartController' \
<chart>.js d3Loader.js data.js theme.js utils.js graphql.js <chart>.js-meta.xml <chart>.html <chart>.css __tests__/*.js
# 2. Stale config keys: confirm every advancedConfig key the tests set is one renderChart reads.
# 3. Dead surface: every @api property AND every <property> in the meta is read by the component.
# (Counting `this.<prop>` reads: the @api declaration line contains no `this.`, so a count of
# exactly 1 IS one real read — treating <=1 as dead produces false positives. Wave-4 finding.)
# 4. Test-name ↔ behavior: each renamed it() asserts what its description claims.
# Import ban:
grep -nE 'from "c/(d3Lib|dataService|themeService|chartUtils|graphqlService)"|@salesforce/apex' \
<chart>.js d3Loader.js data.js theme.js utils.js graphql.js __tests__/*.js(The repo hook blocks Bash grep -r; the commands above are non-recursive on an
explicit file list, which is allowed.) In Wave 0 the only survivor was one
integration-test ABOUTME that still said "Apex … are mocked" — corrected.
npx jest --silent # FULL suite green (the other charts still use shared modules)
npx eslint force-app/main/default/lwc/<chart> # exit 0 (do NOT run repo-wide `npm run lint` — stale aura glob)
npx prettier --write <only files you touched> # never `npm run prettier` (reformats whole repo)Commit; the husky + lint-staged pre-commit hook re-runs prettier/eslint/related
jest on staged files. Never --no-verify. Live-org render verification belongs
to the release step, not the conversion (SCOPE excludes deploys here). The
release step for a suffixed bundle: deploy the NEW component
(-m "LightningComponentBundle:<newName>" — never wholesale), add its instance
to the wave's d3_graphql_showcase_* page, extend the Playwright manifest +
eyeballed baseline, run the live sweep. The legacy detach → deploy → reattach
dance does not apply — nothing is edited in place on a live page.
@apinames cannot start withdataoron(LWC1107) — they collide withdata-*attributes /on*handlers.graphqlQueryis safe; watch chart props likedataFieldin other bundles.- Aggregate envelope has a double
aggregatewrapper:data.uiapi.aggregate.<Object>.edges[].node.aggregate.<Field>.{value | <fn>.value}. The record envelope does not:data.uiapi.query.<Object>.edges[].node.<Field>.value. The free-text override targets the record envelope (uiapi.query) — an admin who pastes an aggregate query gets no rows. lightning/graphqlv2 jest mock is repo-shared at__mocks__/lightning/graphql.jsviamoduleNameMapper(the pinned sfdx-lwc-jest only ships the v1 stub). It providesgraphql.emit(data),graphql.emitErrors(errs), and agqlthat reconstructs the interpolated string. It is not part of any bundle — leave it.- The shared mock D3 cannot observe summation. The hand-rolled mock D3 that
the unit/
.graphqltiers reuse hard-codesmax: jest.fn(() => 500)— a constant, so it is blind to whether the client-side aggregation actually summed anything. A test that must prove summation (e.g. the free-text path sums duplicate(category, series)keys — §9.3(b)) needs a dedicated real-max mock:max: (arr, acc) => Math.max(...arr.map(acc ?? ((d) => d)))plus ascaleLinear().domainstub that captures every domain call into an array. The test then finds the numeric[0, max]y-scale domain call and divides out the axis headroom to recover the summed total (e.g.Math.round(domain[1] / 1.1) === 350). Pattern:d3StackedBarChart'screateSummationMockD3in its.graphql.test.js. - Module-level shared-const
mockD3leaks a forced throw across describes. The §4.3 render-exception test forcesrenderChartto throw by mutating the mock D3 (e.g. makingmockD3.selectthrow). If the bundle's tests share one module-levelconst mockD3instead of a per-testcreateMockD3()factory, that mutation persists into laterdescribeblocks and reddens unrelated tests (wave2-d3StepChartlost 6 tests to exactly this before adding a guard). Fixes, in order of preference: (1) use the chart's per-testcreateMockD3()factory if it has one; (2) swap in a separatethrowingD3loader return for just that test (d3LineChart's approach); (3) failing both, savemockD3.selectbefore the forced throw and restore it in atry/finallyinside that single test. - Whole-string
gqlinterpolation (gql`${queryString}`) is undocumented but is the same mechanism the structured builders already ship; live-verified per the design. Wave 0 confirms the jest-level mechanism (the mock reconstructs and tests assert the string); org verification is a release-step task, not part of the conversion. - apiVersion 65 floor for dynamic
gql. Do not bump or drop it. - Relative
jest.mockof../d3Loaderis required and it works (§3). Do not try to mockc/d3Loader— the loader is bundle-local, not ac/module. no-useless-return— the collapsedloadDatamust not end with a barereturn;or the lint-staged hook fails the commit.- Do NOT delete the repo-shared
__mocks__/@salesforce/apex/D3ChartController.*.jsfiles. They live at the repo root, not in any bundle, and the 39 not-yet-converted charts' suites still import them viamoduleNameMapper. You only stop referencing them from your bundle's tests; they get deleted in the final teardown wave (withD3ChartControlleritself), not per chart. - No-data flash on self-fetch — if you skip the §4.2 loading-state gate, the spinner clears before the wire emits and the chart flashes the "No data" state. Carry the gate on every conversion.
Bar is an aggregation chart with a Count fallback and drill-down. Every family
below gets an explicit graphqlQuery free-text recipe — the free-text
document is always a flat uiapi.query record set (normalize with the §2.3
normalizeRecordsGeneric, which auto-detects the object key so a blank
objectApiName still works); what differs is the client-side computation that
turns those flat rows into the chart's data shape.
Some charts never had the Apex/soqlQuery/fetchMode surface. Verified:
d3GanttChart has no fetchMode, soqlQuery, filterClause, or
@salesforce/apex import — it already self-fetches through lightning/graphql
with a recordCollection-priority gate. For such a chart, conversion is only:
- Inline the used subsets (§2) and switch to relative imports (§3).
- Add the
graphqlQueryfree-text override with the chart's family-specific normalizer (below) + the empty-record hint. - Confirm the existing recordCollection-priority gate and add the §4.2 loading state if missing.
- Add the FlowScreen target + Flow targetConfig (§5.1).
Skip every "remove Apex / remove soqlQuery / remove fetchMode" step and skip the apex-mock deletions in the tests — there are none. Verify with the §6 grep before assuming; charts vary.
9.1 Aggregation charts (bar family: sortedBar, horizontalBar, lollipop, pie, donut, waffle, funnel, progress, gauge, bullet, dotPlot)
The bar baseline. Structured path: buildAggregateQuery/normalizeAggregate
(Sum/Avg) with a buildRecordQuery Count fallback. Free-text: project
[...new Set([groupByField, valueField].filter(Boolean))] — ONE form for every
operation, Count included — and run the chart's existing client-side
aggregateData (_aggregateRawData). Numbers match the aggregate path because
the client-side group-by sums duplicate keys. The inert extra valueField key a
Count projection carries is harmless (aggregateData never reads it for Count),
and one uniform projection beats a per-operation branch (settled in wave 4;
bar's separate [groupByField] Count branch is historical variance — do not
copy it). dotPlot belongs here, not §9.2 — it is a Cleveland dot plot
(groupByField/valueField/operation over aggregated categories), confirmed
against the real component in wave 4.
9.2 Raw-record charts, no aggregation — incl. date X-axis (line, area, step, difference, slope, variableColorLine, scatter, bubble, sparklineGrid)
These self-fetch raw records via buildRecordQuery + normalizeRecordsGeneric,
not buildAggregateQuery. Their structured path fetches raw, un-summed rows
(same contract as recordCollection) and hands them straight to the component's
own record-shaping step — there is no server-side pre-aggregation and no
aggregate branch in gqlQuery. The date-axis members (line, area, step,
variableColorLine) belong here, NOT in the gantt §9.4 path: each already
imports normalizeRecordsGeneric (not the gantt normalizeRecords) and shapes
dates through its own getDateParser() / processTimeSeriesData() — never
chartUtils.parseDate. Inlining the gantt normalizeRecords or
chartUtils.parseDate on these charts is dead surface.
One normalizer, both paths. Use the single auto-detecting
normalizeRecordsGeneric (§2.3) for both the structured and the free-text
path — it auto-detects the object key, so a blank objectApiName still works.
graphql.js inlines only buildRecordQuery + normalizeRecordsGeneric +
buildWhere/formatValue/OPERATORS (no aggregate builder, no aggregate
normalizer). Keep all date/record shaping in the component's existing
getDateParser / processTimeSeriesData (or processEntityData for the grid);
graphql.js does no date work.
Parity invariant = "free-text feeds the same shaping step as structured," NOT
§9.3(b) summation. Because the structured path fetches raw records and never
pre-sums, the free-text path already matches it once both run through the same
processTimeSeriesData / processEntityData. Do NOT add a client-side
summation to the free-text branch — that would INTRODUCE a divergence the
structured path does not have. (Contrast §9.3(b): stacked-bar's structured path
arrives server-pre-summed, so there the free-text branch MUST sum to catch up —
reasoning specific to the pre-summed families that does not transfer here.)
Prove parity, not summation: a multi-series free-text response renders N paths; an
area/stacked response routes through the same d3.stack()/pivot as structured.
Unified-wire handler — the recommended shape for a new raw-record conversion.
Prefer a single @wire handler that runs normalizeRecordsGeneric →
processTimeSeriesData/processEntityData on every emission, with the
free-text branch adding only the empty-record record-query hint (§4.2). All
four Wave-2 line-family charts used this shape; it is cleaner than bar's §4.2
two-branch template and structurally eliminates the path-parity divergence — there
is exactly one shaping path, so free-text cannot drift from structured. Bar's
two-branch template stays fine for aggregation charts; for raw-record charts,
reach for the unified handler.
sparklineGrid is the one member that genuinely buckets/sums — it has an
operation @api and groups by entityField, so processEntityData sums
duplicate (entity, month) keys. But it sums identically on both paths (the
same processEntityData bucket runs for structured and free-text), so there is
still no free-text-only summation step and the unified-wire invariant holds.
Its sub-margin renderChart bail threshold (§4.3) is grid chrome, not a
plotting margin — labelWidth 120 + valueWidth 80 + 40 = 240px.
Per-chart JSDoc, not verbatim. The §9.3(b) "copy graphql.js JSDoc verbatim
between siblings" rule is matrix-family-scoped. On raw-record charts the
normalizeRecordsGeneric docblock legitimately differs per chart — each names its
own field projection ([xField, yField], [xField, yField, sizeField] for
bubble, the date + value pair for the time-series members) — so do not force
byte-identical docblocks here.
Field projection: normalize with normalizeRecordsGeneric projecting the
chart's field set ([xField, yField]; [xField, yField, sizeField] for bubble;
the date + value fields for the time-series members), then feed the chart's
existing record-shaping (date parsing, sorting, sampling). Use the §9.3(b) blessed
[...new Set([...].filter(Boolean))] dedup form.
Go-forward — record-limit constant convention. Declare the per-chart record
cap as a single-key object const CHART_LIMITS = { <CHART_KEY>: n };
(line/step/variableColorLine/sparklineGrid shipped this shape; area shipped a bare
scalar). Use the object shape for future raw-record conversions. Convention for
new work only — do NOT rewrite already-converted bundles to match.
Go-forward cleanup nit (log, do not fix mid-conversion): d3LineChart's
.js-meta.xml dateField default is "CreatedDate" while its JS @api dateField
default is "CloseDate". Reconcile in a dedicated cleanup pass, not inside a
conversion.
Wave 1 converted three stacked-bar members and found the original single section conflated two genuinely different data paths. It splits into two sub-families that do not share a client-side shaping step — pick the one your chart belongs to.
Structured path: grouped rows → buildMatrix / buildHierarchy (from
dataService) → the chart's D3 shape. Free-text: the pasted document returns
flat uiapi.query records — one row per source record, un-summed. So you MUST
pivot+sum client-side before feeding buildMatrix/buildHierarchy, or the
numbers will not match the structured aggregate path (which sums server-side):
normalizeRecordsGenericwith the matrix/hierarchy field list.- Group rows by the composite key (
groupByField|seriesField, or the full hierarchy path) into a Map, summingvalueFieldper key with the chart'soperation— a small reducer for hierarchy, oraggregateSeriesDatafor a two-field matrix. - Feed the summed rows to
buildMatrix/buildHierarchy(both accept flat rows) → the chart's D3 shape.
These members converted in Wave 1. They never touch buildMatrix /
buildHierarchy — inlining or reaching for those here is dead surface.
Their pipeline is:
- Structured, series set:
buildMultiGroupQuery+normalizeMultiGroup(two-field group). The wire result arrives pre-summed by the server — do NOT re-sum on this branch. - Free-text:
normalizeRecordsGeneric([groupByField, seriesField, valueField])→aggregateSeriesData(pivot + SUM of duplicate(category, series)keys) → the chart's existing in-render pivot +d3.stack(). The flat rows arrive un-summed, so this branch MUST sum them or free-text numbers diverge from the pre-summed structured path. - Count (either path) projects
[groupByField, seriesField]— novalueField, matching the structured Count record query — and counts client-side throughaggregateSeriesData.
Mandatory-vs-optional series decision rule (decide per member). This governs which aggregators the bundle imports, and it is the difference between a clean conversion and shipping dead surface:
- Mandatory series —
d3NormalizedBar. A 100%-composition chart is meaningless without a composition dimension; its_aggregateRawDatathrows whenseriesFieldis blank. So drop the single-field trioaggregateData/buildAggregateQuery/normalizeAggregate— there is no no-series path for them to serve, and importing them is dead surface (hygiene check 3). Route Count throughaggregateSeriesData(it supports Count viagroup.count), fed by abuildRecordQueryraw fetch. - Optional series —
d3StackedBarChart,d3StackedHorizontalBar(default@api seriesField = ""). Keep both aggregator sets.seriesFieldpresent →buildMultiGroupQuery/normalizeMultiGroup+aggregateSeriesData;seriesFieldblank → the single-fieldbuildAggregateQuery/normalizeAggregate+aggregateDatano-series path. Removing the single-field trio here would cut a live path.
Field-projection dedup (blessed, all Count/free-text projections). Build every
projected field list as [...new Set([groupByField, seriesField, valueField].filter(Boolean))].
The .filter(Boolean) drops a blank seriesField or an omitted valueField
(Count); the Set guards against a collision when two mappings name the same
field (e.g. groupByField === seriesField), which would otherwise malform the
query. This echoes the repo CLAUDE.md SOQL field-set dedup lesson — apply it in
every family's Count and free-text projection, not only here.
Canonical matrix-module wording (keep siblings byte-identical). Adopt
d3NormalizedBar's buildMultiGroupQuery error string as canonical for the
matrix builders — when an operation has no GraphQL aggregate function, throw:
`Aggregate operation not supported on the GraphQL aggregate path: ${operation} (Count fetches bounded raw records and counts client-side)`
The parenthetical tells the reader why Count is absent from AGG_FN, which the
terser bar-era wording did not. The graphql.js JSDoc for buildMultiGroupQuery,
normalizeMultiGroup, and normalizeRecordsGeneric should be copied verbatim
between the matrix siblings — they are the same functions, and wording drift
between bundles is pure review noise. This verbatim rule is
matrix-family-scoped: on §9.2 raw-record charts the normalizeRecordsGeneric
docblock legitimately differs per chart (each names its own field projection), so
do not force byte-identical docblocks there.
Prove the summation with a real-max mock. The client-side pivot+sum above is
invisible to the shared max: () => 500 mock D3, so a passing test proves
nothing. Use the dedicated real-max / domain-capture mock (§8,
createSummationMockD3 pattern): emit a free-text response with a duplicated
(category, series) key and assert the recovered stacked total is the summed
value, not last-wins.
Meta help text: state that the free-text query selects the two group fields + the value field as top-level node fields.
Scope: this path is for gantt and calendarHeatmap only. The time-series
line-family (line, area, step, variableColorLine) does NOT live here —
those are §9.2 raw-record charts with a date X-axis (component-owned
getDateParser/processTimeSeriesData, single auto-detecting
normalizeRecordsGeneric). Routing a line-family chart through the gantt
normalizeRecords + chartUtils.parseDate path is exactly the dead-surface
mistake §9.2 warns against. What distinguishes the two §9.4 charts is that their
structured render depends on a fixed record shape the generic normalizer does
not produce.
gantt/calendarHeatmap's structured path uses the gantt-specific normalizeRecords
(fixed {label,start,end} shape) and/or parseDate / computeDateExtent from
chartUtils. Free-text: map the generic records to the chart's shape
explicitly —
normalizeRecordsGeneric(data, { fields: [labelField, startField, endField] })
then rows.map(r => ({ label: r[labelField], start: r[startField], end: r[endField] })),
running each date through the inlined parseDate. Critical: the gantt
normalizeRecords indexes strictly by objectApiName, so it returns [] for a
free-text query when objectApiName is blank; the free-text path must use the
auto-detecting normalizeRecordsGeneric (§2.3) instead. Here — unlike the §9.2
line-family — you keep both: normalizeRecords for the structured wire (the
render depends on its fixed {label,start,end} shape), normalizeRecordsGeneric
for free-text. Do not blindly substitute one for the other.
These had server-side stat Apex that is already computed client-side on the graphql path, so deleting the Apex branch is sufficient — no new math. The server-stat methods a converter will encounter and remove:
getStatistics— histogram (count/min/max/mean/median/stdDev)getCorrelation— scatter (Pearson r, slope, intercept)getMultiGroupData— heatmap/stacked (two-field group)getXYData— scatter/bubble raw XYgetDateRangeData— gantt raw date ranges
Free-text: project the raw fields with normalizeRecordsGeneric and feed the
chart's existing client-side statistic (the same code the old graphql fetchMode
already used — computeQuartiles for boxPlot, the histogram binning math, the
scatter correlation reducer). Require a test that emits a raw-record wire
payload and asserts the client-side stat output (e.g. boxPlot quartiles, scatter
r), not just that the chart renders — this is the behavior the removed Apex used
to guarantee.
- Count-bounded caveat: any chart whose Count path uses
buildRecordQuery(record query, then client-side count) is bounded to the firstrecordLimit(default 2000) rows — GraphQL has no server COUNT. Keep that sentence on theoperationproperty (§5), separate from the free-text contract. - objectApiName label: widen to "Object API Name" + self-fetch note only when the chart has drill-down to disambiguate; otherwise just name it the query object (§5).
- Field-set dedup: every Count/free-text projection uses the blessed
[...new Set([...fields].filter(Boolean))]form (§9.3(b)) — a field collision or a blank/omitted mapping otherwise malforms the query. Applies to the bar (§9.1) and raw-record (§9.2) families too, not just the stacked family. - Free-text bound:
recordLimitnever bounds the free-text path (§5) — the admin document's ownfirst:clause is the only cap. Flexipage/showcase instances of record-drawn charts (one mark per record) must pinfirst:AND anorderByin the free-text document so the render — and any committed pixel baseline — is deterministic.
- Free-text validation gap is ACCEPTED — no runtime guard.
normalizeRecordsGenericsets every projected key (null when absent), so a free-text Sum query missing the value field aggregates to zero bars rather than erroring. This footgun is documented in thegraphqlQuerymeta help text (§5); do not add a runtime field-presence check. - Free-text is record-query-only (
uiapi.query). The empty-result path emits a hint naming the record-query contract (§4.2). The meta help text says "record query". - objectApiName rename is conditional per §5 (only when it disambiguates drill-down vs query-object).
- Describe-block accuracy is mandatory (§4.1, hygiene check 4).