Staff report: add CustomField → StaffFieldDescriptor mapper with tests

This commit is contained in:
Joel Brock
2026-06-05 14:45:20 -07:00
parent d737950a3d
commit d31faf2def
3 changed files with 185 additions and 1 deletions
+83
View File
@@ -0,0 +1,83 @@
// @ts-check
/**
* Map a CiviCRM CustomField row (as returned by APIv4 CustomField.get with
* `custom_group_id.name` and `custom_group_id.title` joined in) to a
* StaffFieldDescriptor for the staff report.
*
* Unknown data_type/html_type combinations fall back to "text" and the
* caller should log a warning naming the field so we notice schema
* additions we haven't modeled yet.
*
* Written as JS+JSDoc rather than TS so Node's built-in --test runner
* can import this file directly without any tooling. TypeScript callers
* still get full types via the JSDoc annotations.
*
* @typedef {import("../types/form").StaffFieldDescriptor} StaffFieldDescriptor
* @typedef {import("../types/form").StaffRenderKind} StaffRenderKind
*
* @typedef {object} CustomFieldRow
* @property {string} name
* @property {string} label
* @property {string} data_type
* @property {string} html_type
* @property {number | null | undefined} option_group_id
* @property {number} weight
* @property {string} "custom_group_id.name"
* @property {string} "custom_group_id.title"
*/
const ORG_GROUP_NAMES = new Set(["Food_Co_op_Organizing"]);
/**
* @param {CustomFieldRow} row
* @returns {StaffFieldDescriptor}
*/
export function mapCustomFieldRow(row) {
const groupName = row["custom_group_id.name"];
const groupTitle = row["custom_group_id.title"];
/** @type {"activity" | "org"} */
const groupKind = ORG_GROUP_NAMES.has(groupName) ? "org" : "activity";
const optionGroupId =
typeof row.option_group_id === "number" && row.option_group_id > 0
? row.option_group_id
: undefined;
const render = inferRender(row.data_type, row.html_type, optionGroupId);
return {
groupName,
groupTitle,
groupKind,
civiField: `${groupName}.${row.name}`,
name: row.name,
label: row.label,
render,
optionGroupId,
};
}
/**
* @param {string} dataType
* @param {string} htmlType
* @param {number | undefined} optionGroupId
* @returns {StaffRenderKind}
*/
function inferRender(dataType, htmlType, optionGroupId) {
if (dataType === "Money") return "currency";
if (dataType === "Date") return "date";
if (dataType === "Timestamp") return "datetime";
if (dataType === "Boolean") return "boolean";
if (dataType === "File") return "file";
if (dataType === "Memo") return "longtext";
if (optionGroupId !== undefined) {
if (htmlType === "CheckBox" || htmlType === "Multi-Select") return "multiselect";
if (htmlType === "Select" || htmlType === "Radio" || htmlType === "Autocomplete-Select") {
return "select";
}
}
if (dataType === "Int" || dataType === "Float") return "number";
return "text";
}
+100
View File
@@ -0,0 +1,100 @@
// Run with: npm run test:mapping
import test from "node:test";
import assert from "node:assert/strict";
import { mapCustomFieldRow } from "./staff-field-mapping.mjs";
const baseRow = {
name: "Sample",
label: "Sample",
data_type: "String",
html_type: "Text",
option_group_id: null,
weight: 1,
"custom_group_id.name": "Stage_1",
"custom_group_id.title": "Stage 1",
};
test("Money → currency", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "Money" });
assert.equal(d.render, "currency");
});
test("Date + Select Date → date", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "Date", html_type: "Select Date" });
assert.equal(d.render, "date");
});
test("Timestamp → datetime", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "Timestamp" });
assert.equal(d.render, "datetime");
});
test("Boolean → boolean", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "Boolean" });
assert.equal(d.render, "boolean");
});
test("File → file", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "File" });
assert.equal(d.render, "file");
});
test("Memo → longtext", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "Memo" });
assert.equal(d.render, "longtext");
});
test("Select with option_group_id → select", () => {
const d = mapCustomFieldRow({
...baseRow,
data_type: "String",
html_type: "Select",
option_group_id: 140,
});
assert.equal(d.render, "select");
assert.equal(d.optionGroupId, 140);
});
test("Radio with option_group_id → select", () => {
const d = mapCustomFieldRow({ ...baseRow, html_type: "Radio", option_group_id: 140 });
assert.equal(d.render, "select");
});
test("CheckBox with option_group_id → multiselect", () => {
const d = mapCustomFieldRow({ ...baseRow, html_type: "CheckBox", option_group_id: 140 });
assert.equal(d.render, "multiselect");
});
test("Int without options → number", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "Int", html_type: "Text" });
assert.equal(d.render, "number");
});
test("Float without options → number", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "Float", html_type: "Text" });
assert.equal(d.render, "number");
});
test("Unknown combo → text fallback", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "WeirdNewType", html_type: "Mystery" });
assert.equal(d.render, "text");
});
test("Food_Co_op_Organizing group → org kind", () => {
const d = mapCustomFieldRow({
...baseRow,
"custom_group_id.name": "Food_Co_op_Organizing",
"custom_group_id.title": "Org",
});
assert.equal(d.groupKind, "org");
});
test("Stage_1 group → activity kind", () => {
const d = mapCustomFieldRow({ ...baseRow, "custom_group_id.name": "Stage_1" });
assert.equal(d.groupKind, "activity");
});
test("civiField is built from group + name", () => {
const d = mapCustomFieldRow({ ...baseRow, name: "Foo", "custom_group_id.name": "Stage_2" });
assert.equal(d.civiField, "Stage_2.Foo");
});
+2 -1
View File
@@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"sync-help": "node --env-file=.env.local scripts/sync-help-from-civi.mjs"
"sync-help": "node --env-file=.env.local scripts/sync-help-from-civi.mjs",
"test:mapping": "node --test lib/staff-field-mapping.test.mjs"
},
"dependencies": {
"next": "16.2.6",