mirror of
https://github.com/svm0n/datadeck.git
synced 2026-07-22 08:31:46 +00:00
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:
parent
9d604d5b73
commit
0aa57b978c
6 changed files with 123 additions and 22 deletions
28
main.js
28
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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] || "—");
|
||||
|
|
|
|||
|
|
@ -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 () {};
|
||||
|
|
|
|||
|
|
@ -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() {} }
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue