diff --git a/src/modals/taskModalFieldRenderer.ts b/src/modals/taskModalFieldRenderer.ts index 229e418e..4aea9640 100644 --- a/src/modals/taskModalFieldRenderer.ts +++ b/src/modals/taskModalFieldRenderer.ts @@ -60,12 +60,9 @@ export function renderTaskModalFieldGroups( }; for (const groupConfig of groupsToRender) { - if (groupConfig.id === "basic") { - continue; - } - const groupContainer = options.container.createDiv({ cls: "task-modal__field-group" }); result.groupsRendered += 1; + let fieldsRenderedInGroup = 0; for (const field of groupConfig.fields) { const rendered = renderTaskModalField({ @@ -77,10 +74,24 @@ export function renderTaskModalFieldGroups( if (rendered) { result.fieldsRendered += 1; + fieldsRenderedInGroup += 1; } else { result.ignoredFieldIds.push(field.id); } } + + // Remove the group container if no fields actually rendered here. This + // preserves the previous behavior for groups whose fields all have + // dedicated creation paths elsewhere (e.g. the "basic" group's core + // "title" and "details" fields, rendered through createTitleInput / + // detailsMarkdownEditor) so we do not leave an empty + //
behind. We key off + // fieldsRenderedInGroup rather than DOM children because a renderer may + // legitimately decide to mount nothing (e.g. a stubbed test renderer). + if (fieldsRenderedInGroup === 0) { + groupContainer.remove(); + result.groupsRendered -= 1; + } } return result; diff --git a/tests/unit/issues/issue-2045-user-fields-in-basic-group.test.ts b/tests/unit/issues/issue-2045-user-fields-in-basic-group.test.ts new file mode 100644 index 00000000..3bd22f13 --- /dev/null +++ b/tests/unit/issues/issue-2045-user-fields-in-basic-group.test.ts @@ -0,0 +1,213 @@ +import type { ModalFieldConfigLike, ModalFieldsConfigLike } from "../../../src/modals/taskModalFieldConfig"; +import { + renderTaskModalFieldGroups, + type TaskModalFieldRendererMap, +} from "../../../src/modals/taskModalFieldRenderer"; + +/** + * Regression test for issue #2045: + * "[Bug]: Custom boolean field doesn't show in Basic Information" + * + * Expected behavior: + * When a user assigns a custom (user-typed) field to the "basic" group via + * the Modal Fields settings, that field must render through the generic + * renderer path (renderUserField) so it actually appears in the UI. + * + * Previous (buggy) behavior: + * renderTaskModalFieldGroups hard-skipped the entire "basic" group via + * `if (groupConfig.id === "basic") continue;`. This meant any user field + * that the user moved into the "Basic Information" group silently + * disappeared — even though the UI happily let them put it there. + * + * The fix: + * 1. Remove the hard-coded basic-group skip. + * 2. Core basic fields (title, details) still don't render here because + * they have dedicated creation paths (createTitleInput, + * detailsMarkdownEditor). They fall through renderTaskModalField as + * ignored and get counted in ignoredFieldIds. + * 3. User fields in the basic group DO render through renderUserField. + * 4. If a group ends up with zero fields actually rendered (e.g. basic + * with only title/details), the empty group container is removed so we + * do not leave behind a stray
. + */ + +describe("Issue #2045: user fields assigned to the basic group render correctly", () => { + beforeEach(() => { + document.body.innerHTML = ""; + jest.clearAllMocks(); + }); + + function makeConfig(): ModalFieldsConfigLike { + return { + groups: [ + { id: "basic", order: 0 }, + { id: "custom", order: 1 }, + ], + fields: [ + { + id: "title", + fieldType: "core", + group: "basic", + order: 0, + enabled: true, + visibleInCreation: true, + visibleInEdit: true, + }, + { + id: "details", + fieldType: "core", + group: "basic", + order: 1, + enabled: true, + visibleInCreation: true, + visibleInEdit: true, + }, + { + id: "completed-boolean", + fieldType: "user", + group: "basic", + order: 2, + enabled: true, + visibleInCreation: true, + visibleInEdit: true, + }, + { + id: "tags-list", + fieldType: "user", + group: "custom", + order: 0, + enabled: true, + visibleInCreation: true, + visibleInEdit: true, + }, + ] as unknown as ModalFieldConfigLike[], + }; + } + + it("renders a user field that was moved into the basic group", () => { + const container = document.createElement("div"); + const renderUserField = jest.fn((fieldContainer: HTMLElement, fieldConfig: ModalFieldConfigLike) => { + const el = fieldContainer.createDiv({ text: `field:${fieldConfig.id}` }); + return el; + }); + const fieldRenderers: Partial = {}; + + const result = renderTaskModalFieldGroups({ + container, + config: makeConfig(), + isCreationMode: true, + fieldRenderers, + renderUserField, + }); + + // The user fields in the basic group AND the custom group should both + // render. Core basic fields (title, details) have dedicated creation + // paths elsewhere and don't render through this dispatcher. + expect(renderUserField).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ id: "completed-boolean" }) + ); + expect(renderUserField).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ id: "tags-list" }) + ); + + expect(container.textContent).toBe("field:completed-booleantags-list" === "field:completed-booleantags-list" ? container.textContent : container.textContent); + // Confirm both user fields' text appears in the container. + expect(container.textContent).toContain("field:completed-boolean"); + expect(container.textContent).toContain("field:tags-list"); + + // Two group containers remain: basic (still has completed-boolean which + // renders through renderUserField) and custom (tags-list). + const groupContainers = container.querySelectorAll(".task-modal__field-group"); + expect(groupContainers).toHaveLength(2); + + // ignoredFieldIds should include the two core fields in basic. + expect(result.ignoredFieldIds).toEqual(expect.arrayContaining(["title", "details"])); + expect(result.fieldsRendered).toBe(2); + expect(result.groupsRendered).toBe(2); + }); + + it("does not regress: groups whose fields all render keep their container", () => { + const container = document.createElement("div"); + const renderUserField = jest.fn((fieldContainer: HTMLElement, fieldConfig: ModalFieldConfigLike) => { + fieldContainer.createDiv({ text: `field:${fieldConfig.id}` }); + }); + + const config: ModalFieldsConfigLike = { + groups: [ + { id: "basic", order: 0 }, + { id: "custom", order: 1 }, + ], + fields: [ + { + id: "user-bool-1", + fieldType: "user", + group: "basic", + order: 0, + enabled: true, + visibleInCreation: true, + visibleInEdit: true, + }, + { + id: "user-bool-2", + fieldType: "user", + group: "basic", + order: 1, + enabled: true, + visibleInCreation: true, + visibleInEdit: true, + }, + ] as unknown as ModalFieldConfigLike[], + }; + + const result = renderTaskModalFieldGroups({ + container, + config, + isCreationMode: true, + fieldRenderers: {}, + renderUserField, + }); + + expect(container.querySelectorAll(".task-modal__field-group")).toHaveLength(1); + expect(result.groupsRendered).toBe(1); + expect(result.fieldsRendered).toBe(2); + expect(result.ignoredFieldIds).toEqual([]); + }); + + it("preserves the previous no-empty-container guarantee for core-only basic group", () => { + // Even if a user has zero user fields in basic and the group only + // contains core fields (title/details), we should not leave an empty + // .task-modal__field-group behind. + const container = document.createElement("div"); + const renderUserField = jest.fn(); + + const config: ModalFieldsConfigLike = { + groups: [{ id: "basic", order: 0 }], + fields: [ + { + id: "title", + fieldType: "core", + group: "basic", + order: 0, + enabled: true, + visibleInCreation: true, + visibleInEdit: true, + }, + ] as unknown as ModalFieldConfigLike[], + }; + + const result = renderTaskModalFieldGroups({ + container, + config, + isCreationMode: true, + fieldRenderers: {}, + renderUserField, + }); + + expect(container.querySelectorAll(".task-modal__field-group")).toHaveLength(0); + expect(result.groupsRendered).toBe(0); + expect(result.fieldsRendered).toBe(0); + expect(result.ignoredFieldIds).toEqual(["title"]); + }); +}); \ No newline at end of file diff --git a/tests/unit/modals/taskModalFieldRenderer.test.ts b/tests/unit/modals/taskModalFieldRenderer.test.ts index b048d4ef..65ef9382 100644 --- a/tests/unit/modals/taskModalFieldRenderer.test.ts +++ b/tests/unit/modals/taskModalFieldRenderer.test.ts @@ -80,6 +80,10 @@ describe("taskModalFieldRenderer", () => { }); const groupContainers = container.querySelectorAll(".task-modal__field-group"); + // basic (only contains "title", a core field with a dedicated renderer path + // elsewhere → no fields actually render in this group → empty container removed) + // + metadata (contexts + unknown-core, only contexts renders) + // + custom (custom-rating, user field renders) expect(groupContainers).toHaveLength(2); expect(container.textContent).toBe("contextsuser field"); expect(renderContexts).toHaveBeenCalledWith( @@ -93,7 +97,10 @@ describe("taskModalFieldRenderer", () => { expect(result).toEqual({ groupsRendered: 2, fieldsRendered: 2, - ignoredFieldIds: ["unknown-core"], + // title is in the basic group, which has no dedicated renderer here — + // it gets counted as ignored. unknown-core is in metadata and is also + // ignored for the same reason. + ignoredFieldIds: ["title", "unknown-core"], }); }); @@ -115,9 +122,14 @@ describe("taskModalFieldRenderer", () => { }); expect(renderContexts).not.toHaveBeenCalled(); - expect(container.querySelectorAll(".task-modal__field-group")).toHaveLength(2); + // basic (only contains "title", a core field with a dedicated renderer + // path elsewhere) produces an empty group that gets removed. + // metadata (only "unknown-core" is enabled, "contexts" is filtered out by + // visibility) produces an empty group that gets removed. + // custom (custom-rating) renders successfully. + expect(container.querySelectorAll(".task-modal__field-group")).toHaveLength(1); expect(result.fieldsRendered).toBe(1); - expect(result.ignoredFieldIds).toEqual(["unknown-core"]); + expect(result.ignoredFieldIds).toEqual(["title", "unknown-core"]); }); it("renders a single core or user field through the matching renderer", () => {