From d31faf2defd7c4a4b5fe74c6e9660f30f215d063 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Fri, 5 Jun 2026 14:45:20 -0700 Subject: [PATCH] =?UTF-8?q?Staff=20report:=20add=20CustomField=20=E2=86=92?= =?UTF-8?q?=20StaffFieldDescriptor=20mapper=20with=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/staff-field-mapping.mjs | 83 +++++++++++++++++++++++++ lib/staff-field-mapping.test.mjs | 100 +++++++++++++++++++++++++++++++ package.json | 3 +- 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 lib/staff-field-mapping.mjs create mode 100644 lib/staff-field-mapping.test.mjs diff --git a/lib/staff-field-mapping.mjs b/lib/staff-field-mapping.mjs new file mode 100644 index 0000000..081a3ea --- /dev/null +++ b/lib/staff-field-mapping.mjs @@ -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"; +} diff --git a/lib/staff-field-mapping.test.mjs b/lib/staff-field-mapping.test.mjs new file mode 100644 index 0000000..744b5de --- /dev/null +++ b/lib/staff-field-mapping.test.mjs @@ -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"); +}); diff --git a/package.json b/package.json index eeba4ba..0dd6f7e 100644 --- a/package.json +++ b/package.json @@ -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",