From 38b8fc0e9927c4146b4c8b309b2bcc644abd63d0 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Wed, 25 Feb 2026 23:02:35 +0900 Subject: feat(decks): add default note type setting per deck Allow each deck to specify a default note type that is auto-selected when creating new notes. Includes DB schema migration, server API updates, sync layer support, and UI for editing the default in the deck settings modal. Co-Authored-By: Claude Opus 4.6 --- drizzle/0005_certain_stick.sql | 3 + drizzle/meta/0005_snapshot.json | 992 ++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/client/atoms/decks.ts | 1 + src/client/components/CreateNoteModal.tsx | 15 +- src/client/components/EditDeckModal.test.tsx | 58 +- src/client/components/EditDeckModal.tsx | 60 +- src/client/db/index.test.ts | 1 + src/client/db/index.ts | 12 + src/client/db/repositories.test.ts | 16 + src/client/pages/DeckCardsPage.test.tsx | 1 + src/client/pages/DeckCardsPage.tsx | 10 +- src/client/pages/DeckDetailPage.test.tsx | 1 + src/client/pages/DeckDetailPage.tsx | 5 +- src/client/pages/HomePage.test.tsx | 4 + src/client/sync/conflict.test.ts | 20 + src/client/sync/conflict.ts | 1 + src/client/sync/crdt/concurrent-edits.test.ts | 1 + src/client/sync/crdt/document-manager.test.ts | 9 + src/client/sync/crdt/document-manager.ts | 3 + src/client/sync/crdt/migration.test.ts | 1 + src/client/sync/crdt/repositories.test.ts | 6 + src/client/sync/crdt/types.test.ts | 2 + src/client/sync/crdt/types.ts | 1 + src/client/sync/manager.test.ts | 2 + src/client/sync/pull.test.ts | 21 + src/client/sync/pull.ts | 2 + src/client/sync/push.test.ts | 17 + src/client/sync/push.ts | 2 + src/client/sync/queue.test.ts | 12 + src/server/db/schema.ts | 3 + src/server/repositories/deck.test.ts | 1 + src/server/repositories/deck.ts | 3 + src/server/repositories/sync.test.ts | 1 + src/server/repositories/sync.ts | 3 + src/server/repositories/types.ts | 3 + src/server/routes/cards.test.ts | 1 + src/server/routes/decks.test.ts | 1 + src/server/routes/decks.ts | 1 + src/server/routes/notes.test.ts | 1 + src/server/routes/study.test.ts | 1 + src/server/routes/sync.test.ts | 8 + src/server/routes/sync.ts | 1 + src/server/schemas/index.ts | 3 + 44 files changed, 1296 insertions(+), 21 deletions(-) create mode 100644 drizzle/0005_certain_stick.sql create mode 100644 drizzle/meta/0005_snapshot.json diff --git a/drizzle/0005_certain_stick.sql b/drizzle/0005_certain_stick.sql new file mode 100644 index 0000000..379ee7c --- /dev/null +++ b/drizzle/0005_certain_stick.sql @@ -0,0 +1,3 @@ +ALTER TABLE "decks" ADD COLUMN "default_note_type_id" uuid;--> statement-breakpoint +ALTER TABLE "decks" ADD CONSTRAINT "decks_default_note_type_id_note_types_id_fk" FOREIGN KEY ("default_note_type_id") REFERENCES "public"."note_types"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "decks" DROP COLUMN "new_cards_per_day"; \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..489a3c3 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,992 @@ +{ + "id": "dd73ed21-7aea-4a0a-a0bf-04bb8076e635", + "prevId": "4978370b-924e-40b1-9fd4-770994e1a90a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.cards": { + "name": "cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "note_id": { + "name": "note_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_reversed": { + "name": "is_reversed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "front": { + "name": "front", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "back": { + "name": "back", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "due": { + "name": "due", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stability": { + "name": "stability", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "difficulty": { + "name": "difficulty", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "elapsed_days": { + "name": "elapsed_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scheduled_days": { + "name": "scheduled_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reps": { + "name": "reps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lapses": { + "name": "lapses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_review": { + "name": "last_review", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "cards_deck_id_decks_id_fk": { + "name": "cards_deck_id_decks_id_fk", + "tableFrom": "cards", + "tableTo": "decks", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cards_note_id_notes_id_fk": { + "name": "cards_note_id_notes_id_fk", + "tableFrom": "cards", + "tableTo": "notes", + "columnsFrom": [ + "note_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.decks": { + "name": "decks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_note_type_id": { + "name": "default_note_type_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "decks_user_id_users_id_fk": { + "name": "decks_user_id_users_id_fk", + "tableFrom": "decks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decks_default_note_type_id_note_types_id_fk": { + "name": "decks_default_note_type_id_note_types_id_fk", + "tableFrom": "decks", + "tableTo": "note_types", + "columnsFrom": [ + "default_note_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.note_field_types": { + "name": "note_field_types", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "note_type_id": { + "name": "note_type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "note_field_types_note_type_id_note_types_id_fk": { + "name": "note_field_types_note_type_id_note_types_id_fk", + "tableFrom": "note_field_types", + "tableTo": "note_types", + "columnsFrom": [ + "note_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.note_field_values": { + "name": "note_field_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "note_id": { + "name": "note_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "note_field_type_id": { + "name": "note_field_type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "note_field_values_note_id_notes_id_fk": { + "name": "note_field_values_note_id_notes_id_fk", + "tableFrom": "note_field_values", + "tableTo": "notes", + "columnsFrom": [ + "note_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "note_field_values_note_field_type_id_note_field_types_id_fk": { + "name": "note_field_values_note_field_type_id_note_field_types_id_fk", + "tableFrom": "note_field_values", + "tableTo": "note_field_types", + "columnsFrom": [ + "note_field_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.note_types": { + "name": "note_types", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "front_template": { + "name": "front_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "back_template": { + "name": "back_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_reversible": { + "name": "is_reversible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "note_types_user_id_users_id_fk": { + "name": "note_types_user_id_users_id_fk", + "tableFrom": "note_types", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "note_type_id": { + "name": "note_type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "notes_deck_id_decks_id_fk": { + "name": "notes_deck_id_decks_id_fk", + "tableFrom": "notes", + "tableTo": "decks", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notes_note_type_id_note_types_id_fk": { + "name": "notes_note_type_id_note_types_id_fk", + "tableFrom": "notes", + "tableTo": "note_types", + "columnsFrom": [ + "note_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_logs": { + "name": "review_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "scheduled_days": { + "name": "scheduled_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "elapsed_days": { + "name": "elapsed_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "review_logs_card_id_cards_id_fk": { + "name": "review_logs_card_id_cards_id_fk", + "tableFrom": "review_logs", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "review_logs_user_id_users_id_fk": { + "name": "review_logs_user_id_users_id_fk", + "tableFrom": "review_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crdt_documents": { + "name": "crdt_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "binary": { + "name": "binary", + "type": "varchar(1048576)", + "primaryKey": false, + "notNull": true + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "crdt_documents_user_entity_idx": { + "name": "crdt_documents_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "crdt_documents_entity_type_idx": { + "name": "crdt_documents_entity_type_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "crdt_documents_sync_version_idx": { + "name": "crdt_documents_sync_version_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sync_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "crdt_documents_user_id_users_id_fk": { + "name": "crdt_documents_user_id_users_id_fk", + "tableFrom": "crdt_documents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index fe8279e..9d2626d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1767164715079, "tag": "0004_clean_leopardon", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1772025984247, + "tag": "0005_certain_stick", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/client/atoms/decks.ts b/src/client/atoms/decks.ts index 8c8397f..0d20586 100644 --- a/src/client/atoms/decks.ts +++ b/src/client/atoms/decks.ts @@ -6,6 +6,7 @@ export interface Deck { id: string; name: string; description: string | null; + defaultNoteTypeId: string | null; dueCardCount: number; newCardCount: number; totalCardCount: number; diff --git a/src/client/components/CreateNoteModal.tsx b/src/client/components/CreateNoteModal.tsx index 912aea8..cc39bf6 100644 --- a/src/client/components/CreateNoteModal.tsx +++ b/src/client/components/CreateNoteModal.tsx @@ -27,6 +27,7 @@ interface NoteTypeSummary { interface CreateNoteModalProps { isOpen: boolean; deckId: string; + defaultNoteTypeId?: string | null; onClose: () => void; onNoteCreated: () => void; } @@ -34,6 +35,7 @@ interface CreateNoteModalProps { export function CreateNoteModal({ isOpen, deckId, + defaultNoteTypeId, onClose, onNoteCreated, }: CreateNoteModalProps) { @@ -88,10 +90,13 @@ export function CreateNoteModal({ setNoteTypes(data.noteTypes); setHasLoadedNoteTypes(true); - // Auto-select first note type if available - const firstNoteType = data.noteTypes[0]; - if (firstNoteType) { - await fetchNoteTypeDetails(firstNoteType.id); + // Auto-select default note type if specified, otherwise first + const targetNoteType = + (defaultNoteTypeId && + data.noteTypes.find((nt) => nt.id === defaultNoteTypeId)) || + data.noteTypes[0]; + if (targetNoteType) { + await fetchNoteTypeDetails(targetNoteType.id); } } catch (err) { if (err instanceof ApiClientError) { @@ -102,7 +107,7 @@ export function CreateNoteModal({ } finally { setIsLoadingNoteTypes(false); } - }, [fetchNoteTypeDetails]); + }, [fetchNoteTypeDetails, defaultNoteTypeId]); useEffect(() => { if (isOpen && !hasLoadedNoteTypes) { diff --git a/src/client/components/EditDeckModal.test.tsx b/src/client/components/EditDeckModal.test.tsx index b22cb1d..248c74f 100644 --- a/src/client/components/EditDeckModal.test.tsx +++ b/src/client/components/EditDeckModal.test.tsx @@ -8,6 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockPut = vi.fn(); const mockHandleResponse = vi.fn(); +const mockGetNoteTypes = vi.fn(); + vi.mock("../api/client", () => ({ apiClient: { rpc: { @@ -17,6 +19,9 @@ vi.mock("../api/client", () => ({ $put: (args: unknown) => mockPut(args), }, }, + "note-types": { + $get: () => mockGetNoteTypes(), + }, }, }, handleResponse: (res: unknown) => mockHandleResponse(res), @@ -42,6 +47,7 @@ describe("EditDeckModal", () => { id: "deck-123", name: "Test Deck", description: "Test description", + defaultNoteTypeId: null as string | null, }; const defaultProps = { @@ -51,15 +57,25 @@ describe("EditDeckModal", () => { onDeckUpdated: vi.fn(), }; + const noteTypesResponse = { ok: true, _type: "noteTypes" }; + const putResponse = { ok: true, _type: "put" }; + beforeEach(() => { vi.clearAllMocks(); - mockPut.mockResolvedValue({ ok: true }); - mockHandleResponse.mockResolvedValue({ - deck: { - id: "deck-123", - name: "Test Deck", - description: "Test description", - }, + mockPut.mockResolvedValue(putResponse); + mockGetNoteTypes.mockResolvedValue(noteTypesResponse); + mockHandleResponse.mockImplementation((res: unknown) => { + if (res === noteTypesResponse) { + return Promise.resolve({ noteTypes: [] }); + } + return Promise.resolve({ + deck: { + id: "deck-123", + name: "Test Deck", + description: "Test description", + defaultNoteTypeId: null, + }, + }); }); }); @@ -187,6 +203,7 @@ describe("EditDeckModal", () => { json: { name: "Updated Deck", description: "Test description", + defaultNoteTypeId: null, }, }); }); @@ -220,6 +237,7 @@ describe("EditDeckModal", () => { json: { name: "Test Deck", description: "New description", + defaultNoteTypeId: null, }, }); }); @@ -243,6 +261,7 @@ describe("EditDeckModal", () => { json: { name: "Test Deck", description: null, + defaultNoteTypeId: null, }, }); }); @@ -266,6 +285,7 @@ describe("EditDeckModal", () => { json: { name: "Deck", description: "Description", + defaultNoteTypeId: null, }, }); }); @@ -299,12 +319,16 @@ describe("EditDeckModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); + render(); + + // Wait for note types to load, then override handleResponse for the PUT + await waitFor(() => { + expect(mockGetNoteTypes).toHaveBeenCalled(); + }); mockHandleResponse.mockRejectedValue( new ApiClientError("Deck name already exists", 400), ); - render(); - await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { @@ -333,12 +357,16 @@ describe("EditDeckModal", () => { it("displays error when handleResponse throws", async () => { const user = userEvent.setup(); + render(); + + // Wait for note types to load, then override handleResponse for the PUT + await waitFor(() => { + expect(mockGetNoteTypes).toHaveBeenCalled(); + }); mockHandleResponse.mockRejectedValue( new ApiClientError("Not authenticated", 401), ); - render(); - await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { @@ -374,12 +402,16 @@ describe("EditDeckModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 400)); - const { rerender } = render( , ); + // Wait for note types to load, then override handleResponse for the PUT + await waitFor(() => { + expect(mockGetNoteTypes).toHaveBeenCalled(); + }); + mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 400)); + // Trigger error await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { diff --git a/src/client/components/EditDeckModal.tsx b/src/client/components/EditDeckModal.tsx index 8e95295..9a79de8 100644 --- a/src/client/components/EditDeckModal.tsx +++ b/src/client/components/EditDeckModal.tsx @@ -1,10 +1,16 @@ -import { type FormEvent, useEffect, useState } from "react"; +import { type FormEvent, useCallback, useEffect, useState } from "react"; import { ApiClientError, apiClient } from "../api"; interface Deck { id: string; name: string; description: string | null; + defaultNoteTypeId: string | null; +} + +interface NoteTypeSummary { + id: string; + name: string; } interface EditDeckModalProps { @@ -22,18 +28,45 @@ export function EditDeckModal({ }: EditDeckModalProps) { const [name, setName] = useState(""); const [description, setDescription] = useState(""); + const [defaultNoteTypeId, setDefaultNoteTypeId] = useState( + null, + ); + const [noteTypes, setNoteTypes] = useState([]); + const [isLoadingNoteTypes, setIsLoadingNoteTypes] = useState(false); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const fetchNoteTypes = useCallback(async () => { + setIsLoadingNoteTypes(true); + try { + const res = await apiClient.rpc.api["note-types"].$get(); + const data = await apiClient.handleResponse<{ + noteTypes: NoteTypeSummary[]; + }>(res); + setNoteTypes(data.noteTypes); + } catch { + // Non-critical: note type list is optional + } finally { + setIsLoadingNoteTypes(false); + } + }, []); + // Sync form state when deck changes useEffect(() => { if (deck) { setName(deck.name); setDescription(deck.description ?? ""); + setDefaultNoteTypeId(deck.defaultNoteTypeId); setError(null); } }, [deck]); + useEffect(() => { + if (isOpen) { + fetchNoteTypes(); + } + }, [isOpen, fetchNoteTypes]); + const handleClose = () => { setError(null); onClose(); @@ -52,6 +85,7 @@ export function EditDeckModal({ json: { name: name.trim(), description: description.trim() || null, + defaultNoteTypeId: defaultNoteTypeId || null, }, }); await apiClient.handleResponse(res); @@ -147,6 +181,30 @@ export function EditDeckModal({ /> +
+ + +
+