fix(add-entry): exclude title/index column from categorical dropdown detection

Co-authored-by: SVM0N <SVM0N@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-07-06 07:19:14 +00:00
parent 9d604d5b73
commit 0aa57b978c
6 changed files with 123 additions and 22 deletions

28
main.js

File diff suppressed because one or more lines are too long

View file

@ -82,6 +82,9 @@ export async function renderAddEntryForm(app: App, source: string, el: HTMLEleme
const dateCols = headers.filter(h => isDateCol(h));
const notesCols = headers.filter(h => isNotesCol(h));
const otherCols = headers.filter(h => !binaryCols.includes(h) && !dateCols.includes(h) && !notesCols.includes(h));
// The title/index column is a free-text identifier — never a categorical
// dropdown, even if it happens to have few distinct existing values.
const titleCol = headers.find(h => ["title", "name"].includes(h.toLowerCase()));
// Render as one collapsible "menu" (Apple-style grouped card):
// - Default state: a single discreet "+ New entry" pill.
@ -137,7 +140,7 @@ export async function renderAddEntryForm(app: App, source: string, el: HTMLEleme
otherCols.forEach(h => {
const row = makeRow(h, "field");
const uniqueVals = new Set(rows.map(r => (r[h] ?? "").trim()).filter(Boolean));
if (looksCategorical(uniqueVals.size)) {
if (h !== titleCol && looksCategorical(uniqueVals.size)) {
const select = row.createEl("select", { cls: "csv-add-row-control" });
select.createEl("option", { text: "—", value: "" });
Array.from(uniqueVals).sort().forEach(v => select.createEl("option", { text: v, value: v }));

View file

@ -107,6 +107,12 @@ export class AddEntryModal extends Modal {
row.createEl("label", { text: titleCase(h), cls: "csv-modal-label" });
const presets = this.optionPresets[h] ?? [];
// The title/index column is always a free-text identifier — never a
// dropdown, whether that's inferred (few distinct values / presets) or
// configured (settings.selectColumns / a future per-file categorical
// list). It's excluded up front so none of the branches below can
// route it into a select.
const isTitleCol = h === titleCol;
// Single-value option columns (preset-backed or configured select) render
// as a native <select>. Crucially this keeps focus *inside* the modal —
// the body-appended showSelectPicker (used elsewhere) loses focus to the
@ -117,9 +123,9 @@ export class AddEntryModal extends Modal {
// few enough to be a closed-ish vocabulary (e.g. Watched, Format) gets a
// dropdown of its values + "Custom…" too — not just the names listed in
// settings.selectColumns. Same heuristic as the mobile add form.
const autoCategorical = !this.isSelectCol(h) && !isMultiValueColName(h)
const autoCategorical = !isTitleCol && !this.isSelectCol(h) && !isMultiValueColName(h)
&& !isDateCol(h) && looksCategorical(this.getColumnValues(h).length);
const useNativeSelect = !this.isNotesCol(h) && (presets.length > 0 || (this.isSelectCol(h) && !isMultiValueColName(h)) || autoCategorical);
const useNativeSelect = !isTitleCol && !this.isNotesCol(h) && (presets.length > 0 || (this.isSelectCol(h) && !isMultiValueColName(h)) || autoCategorical);
if (this.isBooleanCol(h)) {
// Habit-style 0/1 column → a toggle. Off writes "0", on "1", so logging
@ -170,7 +176,7 @@ export class AddEntryModal extends Modal {
}
});
} else if (this.isSelectCol(h)) {
} else if (!isTitleCol && this.isSelectCol(h)) {
// Multi-value select (tags/genres/themes): chip + picker, which the
// picker's "+ Add" / existing-value clicks drive. Still works inside
// the modal because the user clicks options rather than typing into a
@ -324,9 +330,11 @@ export class NoteExpanderModal extends Modal {
// Configured select column, or a pseudo-categorical one (few distinct
// values, e.g. Watched/Format) — both get the chip + option picker, so
// editing offers the same vocabulary the add form does.
const selectLike = this.isSelectCol(h)
|| (!isMultiValueColName(h) && !isDateCol(h) && looksCategorical(this.getColumnValues(h).length));
// editing offers the same vocabulary the add form does. The title/index
// column is excluded — it's a free-text identifier, never a dropdown,
// even if it's short on distinct values or listed in selectColumns.
const selectLike = h !== titleKey && (this.isSelectCol(h)
|| (!isMultiValueColName(h) && !isDateCol(h) && looksCategorical(this.getColumnValues(h).length)));
if (selectLike) {
const chip = fieldRow.createDiv({ cls: `csv-select-chip ${this.row[h] ? "" : "empty"}` });
chip.setText(this.row[h] || "—");

View file

@ -41,6 +41,8 @@ export function setupDom() {
HE.hasClass = function (c) { return this.classList.contains(c); };
HE.setAttr = function (k, v) { this.setAttribute(k, String(v)); return this; };
HE.getAttr = function (k) { return this.getAttribute(k); };
HE.hide = function () { this.style.display = "none"; return this; };
HE.show = function () { this.style.display = ""; return this; };
// jsdom doesn't implement scrollIntoView (used by the picker's keyboard
// cursor and the travel detail panel); a no-op is fine for structure tests.
if (!HE.scrollIntoView) HE.scrollIntoView = function () {};

View file

@ -8,7 +8,19 @@ class Base { constructor() {} }
export class App extends Base {}
export class Component extends Base { load() {} unload() {} registerEvent() {} register() {} }
export class FileView extends Base {}
export class Modal extends Base { open() {} close() {} }
export class Modal extends Base {
constructor(app) {
super(app);
this.app = app;
// Real Obsidian creates these on construction; the view code (contentEl.empty(),
// modalEl.addClass(...), etc.) expects them to exist without a real Modal.open().
this.contentEl = document.createElement("div");
this.modalEl = document.createElement("div");
this.containerEl = document.createElement("div");
}
open() {}
close() {}
}
export class PluginSettingTab extends Base {}
export class Plugin extends Base {}
export class Menu extends Base { addItem() { return this; } showAtMouseEvent() {} }

View file

@ -12,6 +12,7 @@ import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { setupDom } from "./test-support/dom-env.mjs";
import { App as StubApp } from "./test-support/obsidian-stub.mjs";
let passed = 0, failed = 0;
async function test(name, fn) {
@ -642,6 +643,81 @@ await test("looksBoolean: recognizes 0/1/true/false/yes/no/empty vocabularies",
assert(!looksBoolean(["0", "1", "Task"]), "a non-boolean value disqualifies the column");
});
// ── Add Entry / Note Expander modals: title column is never categorical ────
const { AddEntryModal, NoteExpanderModal } = await load("./src/modals.ts");
function openAddModal(headers, rows, overrides = {}) {
const isNotesCol = overrides.isNotesCol ?? (() => false);
const isSelectCol = overrides.isSelectCol ?? (() => false);
const getColumnValues = overrides.getColumnValues
?? ((h) => Array.from(new Set(rows.map(r => r[h] ?? "").filter(Boolean))).sort());
const modal = new AddEntryModal(
new StubApp(), headers, isNotesCol, isSelectCol, getColumnValues, () => {},
overrides.optionPresets ?? {}, overrides.isBooleanCol ?? (() => false),
);
modal.contentEl = document.body.createDiv();
modal.onOpen();
return modal;
}
function fieldRowFor(modal, header) {
return Array.from(modal.contentEl.querySelectorAll(".csv-modal-row"))
.find(r => r.querySelector(".csv-modal-label")?.textContent.toLowerCase() === header.toLowerCase());
}
await test("add-entry: title column stays a text input even with few distinct values", async () => {
const headers = ["Title", "Type"];
const rows = [{ Title: "Alpha", Type: "Task" }, { Title: "Beta", Type: "Task" }];
const modal = openAddModal(headers, rows);
const titleRow = fieldRowFor(modal, "Title");
assert(titleRow.querySelector("input.csv-modal-input"), "title renders as a plain text input");
assert(!titleRow.querySelector("select"), "title never renders as a <select>");
});
await test("add-entry: title column stays text even when configured as a select column", async () => {
const headers = ["Title", "Type"];
const rows = [{ Title: "Alpha", Type: "Task" }];
const modal = openAddModal(headers, rows, { isSelectCol: (h) => h === "Title" });
const titleRow = fieldRowFor(modal, "Title");
assert(titleRow.querySelector("input.csv-modal-input"), "title stays text even if explicitly marked a select column");
assert(!titleRow.querySelector("select"), "no dropdown for title");
});
await test("add-entry: a genuinely categorical non-title column still gets a dropdown", async () => {
const headers = ["Title", "Type"];
const rows = [{ Title: "Alpha", Type: "Task" }, { Title: "Beta", Type: "Idea" }];
const modal = openAddModal(headers, rows);
const typeRow = fieldRowFor(modal, "Type");
assert(typeRow.querySelector("select"), "non-title low-cardinality column still auto-categorizes");
});
function openExpander(row, headers, overrides = {}) {
const isNotesCol = overrides.isNotesCol ?? (() => false);
const isSelectCol = overrides.isSelectCol ?? (() => false);
const getColumnValues = overrides.getColumnValues ?? (() => []);
const modal = new NoteExpanderModal(
new StubApp(), row, overrides.notesCol ?? "", headers, "test.csv",
isNotesCol, isSelectCol, getColumnValues, () => {}, undefined,
);
modal.contentEl = document.body.createDiv();
modal.onOpen();
return modal;
}
await test("note-expander: title column is never rendered as a select chip", async () => {
const headers = ["Title", "Type"];
const row = { Title: "Alpha", Type: "Task" };
const modal = openExpander(row, headers, {
isSelectCol: (h) => h === "Title",
getColumnValues: () => ["Alpha", "Beta"],
});
const fieldRows = Array.from(modal.contentEl.querySelectorAll(".csv-expander-field-row"));
const titleRow = fieldRows.find(r => r.querySelector(".csv-expander-field-label")?.textContent === "Title");
assert(titleRow, "title field row exists");
assert(!titleRow.querySelector(".csv-select-chip"), "title never renders as a select chip");
assert(titleRow.querySelector(".csv-expander-field-value"), "title renders as a plain value field");
});
// ── Multi-select picker ──────────────────────────────────────────────────────
const { showSelectPicker, isMultiValueColName } = await load("./src/utils.ts");