m4.5: lists first-class — create a checklist straight from quick-add
Checklist notes existed (M2) but could only be made by creating a text note and toggling it in the editor — so they were undiscoverable. Now the board's quick-add can make one in one shot. - create endpoint accepts kind + items: POST /api/notes with kind:"list" and items:[...] creates a checklist note and its items atomically. A list note is non-empty when it has a title or ≥1 item. - quick-add gets a checklist toggle (checkbox icon): flip it and each body line becomes an item on save; placeholder switches to "One item per line"; resets to a plain note after close. - notes store create() accepts kind + items; parse_list_items() helper (trims, drops blanks) with a unit test. - title placeholders now read "Title (optional)" in quick-add too. Third item of M4.5. Display/editing of checklists was already built in M2; this closes the creation gap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
import { onBeforeUnmount, onMounted, nextTick, ref } from "vue";
|
import { onBeforeUnmount, onMounted, nextTick, ref } from "vue";
|
||||||
import { useNotesStore } from "../stores/notes";
|
import { useNotesStore } from "../stores/notes";
|
||||||
import ColorPicker from "./ColorPicker.vue";
|
import ColorPicker from "./ColorPicker.vue";
|
||||||
|
import Icon from "./Icon.vue";
|
||||||
import type { NoteColor } from "../notes/colors";
|
import type { NoteColor } from "../notes/colors";
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ autofocus?: boolean }>(), { autofocus: false });
|
const props = withDefaults(defineProps<{ autofocus?: boolean }>(), { autofocus: false });
|
||||||
@@ -13,9 +14,17 @@ const saving = ref(false);
|
|||||||
const title = ref("");
|
const title = ref("");
|
||||||
const body = ref("");
|
const body = ref("");
|
||||||
const color = ref<NoteColor>("default");
|
const color = ref<NoteColor>("default");
|
||||||
|
// 'text' = freeform note; 'list' = checklist (each body line becomes an item).
|
||||||
|
const mode = ref<"text" | "list">("text");
|
||||||
const root = ref<HTMLElement | null>(null);
|
const root = ref<HTMLElement | null>(null);
|
||||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
function toggleMode() {
|
||||||
|
mode.value = mode.value === "list" ? "text" : "list";
|
||||||
|
bodyInput.value?.focus();
|
||||||
|
void nextTick(autoGrow);
|
||||||
|
}
|
||||||
|
|
||||||
async function open() {
|
async function open() {
|
||||||
expanded.value = true;
|
expanded.value = true;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
@@ -45,7 +54,16 @@ async function persist(): Promise<void> {
|
|||||||
if (!hasContent()) return;
|
if (!hasContent()) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
|
if (mode.value === "list") {
|
||||||
|
// Each non-empty body line becomes a checklist item.
|
||||||
|
const items = body.value
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
await notes.create({ title: title.value, body: "", color: color.value, kind: "list", items });
|
||||||
|
} else {
|
||||||
await notes.create({ title: title.value, body: body.value, color: color.value });
|
await notes.create({ title: title.value, body: body.value, color: color.value });
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
@@ -56,6 +74,7 @@ async function commit() {
|
|||||||
await persist();
|
await persist();
|
||||||
clearFields();
|
clearFields();
|
||||||
expanded.value = false;
|
expanded.value = false;
|
||||||
|
mode.value = "text"; // next capture starts as a plain note
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shift+Enter: save the current note and immediately start a fresh one, staying
|
// Shift+Enter: save the current note and immediately start a fresh one, staying
|
||||||
@@ -102,7 +121,7 @@ defineExpose({ open });
|
|||||||
<input
|
<input
|
||||||
v-model="title"
|
v-model="title"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Title"
|
placeholder="Title (optional)"
|
||||||
class="w-full bg-transparent px-1 text-sm font-semibold outline-none placeholder:text-neutral-400"
|
class="w-full bg-transparent px-1 text-sm font-semibold outline-none placeholder:text-neutral-400"
|
||||||
@keydown.enter.shift.prevent="commitAndContinue"
|
@keydown.enter.shift.prevent="commitAndContinue"
|
||||||
@keydown.enter.exact.prevent="bodyInput?.focus()"
|
@keydown.enter.exact.prevent="bodyInput?.focus()"
|
||||||
@@ -110,14 +129,31 @@ defineExpose({ open });
|
|||||||
<textarea
|
<textarea
|
||||||
ref="bodyInput"
|
ref="bodyInput"
|
||||||
v-model="body"
|
v-model="body"
|
||||||
placeholder="Take a note… (Shift+Enter saves & starts a new one)"
|
:placeholder="
|
||||||
|
mode === 'list'
|
||||||
|
? 'One item per line… (Shift+Enter saves)'
|
||||||
|
: 'Take a note… (Shift+Enter saves & starts a new one)'
|
||||||
|
"
|
||||||
class="max-h-64 min-h-[4.5rem] w-full resize-none overflow-y-auto bg-transparent px-1 text-sm outline-none placeholder:text-neutral-400"
|
class="max-h-64 min-h-[4.5rem] w-full resize-none overflow-y-auto bg-transparent px-1 text-sm outline-none placeholder:text-neutral-400"
|
||||||
@input="autoGrow"
|
@input="autoGrow"
|
||||||
@keydown.enter.shift.prevent="commitAndContinue"
|
@keydown.enter.shift.prevent="commitAndContinue"
|
||||||
@keydown.esc="commit"
|
@keydown.esc="commit"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-between gap-2 pt-1">
|
<div class="flex items-center justify-between gap-2 pt-1">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
<ColorPicker v-model="color" />
|
<ColorPicker v-model="color" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-btn"
|
||||||
|
:class="mode === 'list' ? 'text-brand-700 dark:text-brand' : ''"
|
||||||
|
:title="mode === 'list' ? 'Switch to a note' : 'Make a checklist'"
|
||||||
|
:aria-label="mode === 'list' ? 'Switch to a note' : 'Make a checklist'"
|
||||||
|
:aria-pressed="mode === 'list'"
|
||||||
|
@click="toggleMode"
|
||||||
|
>
|
||||||
|
<Icon name="checkbox" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
class="rounded-md px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||||
|
|||||||
@@ -95,7 +95,13 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function create(input: { title: string; body: string; color: NoteColor }): Promise<void> {
|
async function create(input: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
color: NoteColor;
|
||||||
|
kind?: NoteKind;
|
||||||
|
items?: string[];
|
||||||
|
}): Promise<void> {
|
||||||
reconcile(await api.post<Note>("/api/notes", input));
|
reconcile(await api.post<Note>("/api/notes", input));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ def is_empty_note(title: str | None, body: str | None) -> bool:
|
|||||||
return not (title or "").strip() and not (body or "").strip()
|
return not (title or "").strip() and not (body or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_list_items(raw: object) -> list[str]:
|
||||||
|
"""Trimmed, non-empty checklist item texts from a create payload's `items`."""
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
return [s.strip() for s in raw if isinstance(s, str) and s.strip()]
|
||||||
|
|
||||||
|
|
||||||
def normalize_color(color: object) -> str:
|
def normalize_color(color: object) -> str:
|
||||||
return color if color in NOTE_COLORS else "default"
|
return color if color in NOTE_COLORS else "default"
|
||||||
|
|
||||||
@@ -387,7 +394,14 @@ async def create_note():
|
|||||||
data = await request.get_json(silent=True) or {}
|
data = await request.get_json(silent=True) or {}
|
||||||
title = data.get("title") if isinstance(data.get("title"), str) else ""
|
title = data.get("title") if isinstance(data.get("title"), str) else ""
|
||||||
body = data.get("body") if isinstance(data.get("body"), str) else ""
|
body = data.get("body") if isinstance(data.get("body"), str) else ""
|
||||||
if is_empty_note(title, body):
|
kind = data.get("kind") if data.get("kind") in ("text", "list") else "text"
|
||||||
|
# A checklist note's "content" is its items, not the body — so it's non-empty
|
||||||
|
# when it has a title or at least one item (quick-add can create one in one shot).
|
||||||
|
item_texts = parse_list_items(data.get("items")) if kind == "list" else []
|
||||||
|
if kind == "list":
|
||||||
|
if not (title.strip() or item_texts):
|
||||||
|
return jsonify({"error": "note is empty"}), 400
|
||||||
|
elif is_empty_note(title, body):
|
||||||
return jsonify({"error": "note is empty"}), 400
|
return jsonify({"error": "note is empty"}), 400
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
# New notes go to the top of the manual order.
|
# New notes go to the top of the manual order.
|
||||||
@@ -402,11 +416,14 @@ async def create_note():
|
|||||||
title=clean_title,
|
title=clean_title,
|
||||||
display_title=derive_display_title(clean_title, body),
|
display_title=derive_display_title(clean_title, body),
|
||||||
body=body,
|
body=body,
|
||||||
|
kind=kind,
|
||||||
color=normalize_color(data.get("color")),
|
color=normalize_color(data.get("color")),
|
||||||
position=int(max_pos) + 1,
|
position=int(max_pos) + 1,
|
||||||
)
|
)
|
||||||
db.add(note)
|
db.add(note)
|
||||||
await db.flush() # assign note.id before writing links
|
await db.flush() # assign note.id before writing items/links
|
||||||
|
for pos, text in enumerate(item_texts):
|
||||||
|
db.add(NoteItem(note_id=note.id, text=text, position=pos))
|
||||||
await _rewrite_links(db, note)
|
await _rewrite_links(db, note)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
await db.refresh(note)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from thoughtsync.notes import (
|
|||||||
is_empty_note,
|
is_empty_note,
|
||||||
normalize_color,
|
normalize_color,
|
||||||
parse_link_titles,
|
parse_link_titles,
|
||||||
|
parse_list_items,
|
||||||
rewrite_link_title,
|
rewrite_link_title,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -130,6 +131,13 @@ def test_derive_display_title_caps_length():
|
|||||||
assert derive_display_title(long, "body") == "x" * 200
|
assert derive_display_title(long, "body") == "x" * 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_list_items():
|
||||||
|
assert parse_list_items(["milk", " eggs ", "", " ", "bread"]) == ["milk", "eggs", "bread"]
|
||||||
|
assert parse_list_items("not a list") == []
|
||||||
|
assert parse_list_items(None) == []
|
||||||
|
assert parse_list_items([1, "x", None, {"a": 1}]) == ["x"]
|
||||||
|
|
||||||
|
|
||||||
def test_escape_like():
|
def test_escape_like():
|
||||||
# LIKE wildcards in user input must be neutralized so they match literally.
|
# LIKE wildcards in user input must be neutralized so they match literally.
|
||||||
assert _escape_like("100%") == "100\\%"
|
assert _escape_like("100%") == "100\\%"
|
||||||
|
|||||||
Reference in New Issue
Block a user