From fba20f83c5e21dd126ce802dc83f97058388d28c Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Wed, 21 Oct 2026 00:12:34 +0200 Subject: [PATCH] feat: entry editor at /content/:key/new and /content/:key/:id --- features/content_entry_editor.feature | 469 ++++++++++++++++ features/content_entry_editor.qa.md | 69 +++ features/content_entry_list.feature | 2 +- web/bun.lock | 26 +- web/package.json | 2 + web/src/components/BuilderBadge.vue | 8 +- web/src/components/BuilderStatusChip.vue | 49 +- web/src/components/ColumnPickerList.vue | 7 +- web/src/components/ContentEntries.vue | 113 +--- web/src/components/ContentState.vue | 38 +- web/src/components/DashboardShell.vue | 4 + web/src/components/EntryActions.vue | 58 ++ web/src/components/EntryControl.vue | 113 ++++ web/src/components/EntryDateField.vue | 86 +++ web/src/components/EntryDeleteDialog.vue | 46 ++ web/src/components/EntryEditor.vue | 107 ++++ web/src/components/EntryEditorSkeleton.vue | 20 + web/src/components/EntryField.vue | 54 ++ web/src/components/EntryForm.vue | 75 +++ web/src/components/EntryNotices.vue | 70 +++ web/src/components/EntryTable.vue | 7 +- web/src/components/FieldRow.vue | 4 +- web/src/components/FieldTypePicker.vue | 26 +- web/src/components/MarkdownField.vue | 132 +++++ web/src/components/SchemaFieldTable.vue | 9 +- .../components/__tests__/EntryControl.spec.ts | 261 +++++++++ .../__tests__/useContentTypeDetail.spec.ts | 42 ++ .../__tests__/useContentTypes.spec.ts | 94 ---- .../composables/__tests__/useEntries.spec.ts | 177 ++++++ .../__tests__/useEntryLoad.spec.ts | 136 +++++ .../__tests__/useEntryNames.spec.ts | 122 +++++ .../__tests__/useEntryRemoval.spec.ts | 234 ++++++++ .../__tests__/useUserLookup.spec.ts | 50 ++ web/src/composables/useApi.ts | 14 +- web/src/composables/useContentListPage.ts | 98 ++++ web/src/composables/useContentTypeDetail.ts | 12 +- web/src/composables/useContentTypes.ts | 29 +- web/src/composables/useEntries.ts | 53 ++ web/src/composables/useEntryDraft.ts | 162 ++++++ web/src/composables/useEntryEditorPage.ts | 150 ++++++ web/src/composables/useEntryForm.ts | 69 +++ web/src/composables/useEntryList.ts | 6 +- web/src/composables/useEntryLoad.ts | 68 +++ web/src/composables/useEntryNames.ts | 34 ++ web/src/composables/useEntryRemoval.ts | 90 ++++ web/src/composables/useUserLookup.ts | 18 + web/src/i18n/contentMessages.spec.ts | 2 +- web/src/i18n/entryMessages.spec.ts | 58 ++ web/src/i18n/messages.ts | 78 +++ web/src/lib/__tests__/entryCells.spec.ts | 3 + .../lib/__tests__/entryEditorState.spec.ts | 36 ++ web/src/lib/__tests__/entryForm.spec.ts | 218 ++++++++ web/src/lib/__tests__/entryRules.spec.ts | 102 ++++ .../lib/__tests__/entryServerError.spec.ts | 90 ++++ web/src/lib/__tests__/fieldTypes.spec.ts | 149 ++++++ web/src/lib/__tests__/markdown.spec.ts | 94 ++++ web/src/lib/__tests__/schemaFields.spec.ts | 7 +- web/src/lib/apiError.ts | 12 + web/src/lib/contentEndpoints.ts | 8 + web/src/lib/contentNav.ts | 2 +- web/src/lib/contentTypes.ts | 7 +- web/src/lib/entryCells.ts | 38 +- web/src/lib/entryEditorState.ts | 66 +++ web/src/lib/entryEditors.ts | 172 ++++++ web/src/lib/entryForm.ts | 77 +++ web/src/lib/entryLayout.ts | 15 + web/src/lib/entryMessages.ts | 155 ++++++ web/src/lib/entryServerError.ts | 50 ++ web/src/lib/entryStamp.ts | 10 + web/src/lib/entryValue.ts | 59 ++ web/src/lib/fieldTypes.ts | 106 ++++ web/src/lib/markdown.ts | 22 + web/src/lib/markdownEdit.ts | 66 +++ web/src/lib/schemaBuilderDiff.ts | 7 +- web/src/lib/schemaBuilderLoad.ts | 2 +- web/src/lib/schemaBuilderModel.ts | 8 +- web/src/lib/schemaFields.ts | 37 +- web/src/lib/schemaRows.ts | 2 +- web/src/router/index.ts | 12 + web/src/views/ContentListPage.vue | 61 +++ web/src/views/ContentListView.vue | 58 +- web/src/views/EntryEditorPage.vue | 80 +++ web/src/views/EntryEditorView.vue | 41 ++ .../__tests__/EntryEditorControls.spec.ts | 226 ++++++++ .../views/__tests__/EntryEditorDelete.spec.ts | 184 +++++++ .../__tests__/EntryEditorFailures.spec.ts | 197 +++++++ .../views/__tests__/EntryEditorLeave.spec.ts | 137 +++++ .../views/__tests__/EntryEditorLoad.spec.ts | 363 +++++++++++++ .../views/__tests__/EntryEditorSave.spec.ts | 506 ++++++++++++++++++ web/src/views/__tests__/contentTestKit.ts | 3 +- web/src/views/__tests__/entryEditorKit.ts | 157 ++++++ web/src/views/__tests__/schemaTestKit.ts | 32 +- web/visual/check.ts | 3 + web/visual/states.ts | 208 ++++++- 94 files changed, 7116 insertions(+), 423 deletions(-) create mode 100644 features/content_entry_editor.feature create mode 100644 features/content_entry_editor.qa.md create mode 100644 web/src/components/EntryActions.vue create mode 100644 web/src/components/EntryControl.vue create mode 100644 web/src/components/EntryDateField.vue create mode 100644 web/src/components/EntryDeleteDialog.vue create mode 100644 web/src/components/EntryEditor.vue create mode 100644 web/src/components/EntryEditorSkeleton.vue create mode 100644 web/src/components/EntryField.vue create mode 100644 web/src/components/EntryForm.vue create mode 100644 web/src/components/EntryNotices.vue create mode 100644 web/src/components/MarkdownField.vue create mode 100644 web/src/components/__tests__/EntryControl.spec.ts create mode 100644 web/src/composables/__tests__/useContentTypeDetail.spec.ts create mode 100644 web/src/composables/__tests__/useEntries.spec.ts create mode 100644 web/src/composables/__tests__/useEntryLoad.spec.ts create mode 100644 web/src/composables/__tests__/useEntryNames.spec.ts create mode 100644 web/src/composables/__tests__/useEntryRemoval.spec.ts create mode 100644 web/src/composables/__tests__/useUserLookup.spec.ts create mode 100644 web/src/composables/useContentListPage.ts create mode 100644 web/src/composables/useEntries.ts create mode 100644 web/src/composables/useEntryDraft.ts create mode 100644 web/src/composables/useEntryEditorPage.ts create mode 100644 web/src/composables/useEntryForm.ts create mode 100644 web/src/composables/useEntryLoad.ts create mode 100644 web/src/composables/useEntryNames.ts create mode 100644 web/src/composables/useEntryRemoval.ts create mode 100644 web/src/composables/useUserLookup.ts create mode 100644 web/src/i18n/entryMessages.spec.ts create mode 100644 web/src/lib/__tests__/entryEditorState.spec.ts create mode 100644 web/src/lib/__tests__/entryForm.spec.ts create mode 100644 web/src/lib/__tests__/entryRules.spec.ts create mode 100644 web/src/lib/__tests__/entryServerError.spec.ts create mode 100644 web/src/lib/__tests__/fieldTypes.spec.ts create mode 100644 web/src/lib/__tests__/markdown.spec.ts create mode 100644 web/src/lib/apiError.ts create mode 100644 web/src/lib/contentEndpoints.ts create mode 100644 web/src/lib/entryEditorState.ts create mode 100644 web/src/lib/entryEditors.ts create mode 100644 web/src/lib/entryForm.ts create mode 100644 web/src/lib/entryLayout.ts create mode 100644 web/src/lib/entryMessages.ts create mode 100644 web/src/lib/entryServerError.ts create mode 100644 web/src/lib/entryStamp.ts create mode 100644 web/src/lib/entryValue.ts create mode 100644 web/src/lib/fieldTypes.ts create mode 100644 web/src/lib/markdown.ts create mode 100644 web/src/lib/markdownEdit.ts create mode 100644 web/src/views/ContentListPage.vue create mode 100644 web/src/views/EntryEditorPage.vue create mode 100644 web/src/views/EntryEditorView.vue create mode 100644 web/src/views/__tests__/EntryEditorControls.spec.ts create mode 100644 web/src/views/__tests__/EntryEditorDelete.spec.ts create mode 100644 web/src/views/__tests__/EntryEditorFailures.spec.ts create mode 100644 web/src/views/__tests__/EntryEditorLeave.spec.ts create mode 100644 web/src/views/__tests__/EntryEditorLoad.spec.ts create mode 100644 web/src/views/__tests__/EntryEditorSave.spec.ts create mode 100644 web/src/views/__tests__/entryEditorKit.ts diff --git a/features/content_entry_editor.feature b/features/content_entry_editor.feature new file mode 100644 index 0000000..edda07b --- /dev/null +++ b/features/content_entry_editor.feature @@ -0,0 +1,469 @@ +Feature: Entry editor of a custom content type + As a verified user + I want to create, edit and delete an entry of a custom content type + So that the content follows the fields of its type + + Background: + Given the SPA calls the API with relative URLs under /v1 and the "jwt" HttpOnly cookie + And API errors are plain text + And the routes are /content/:key/new and /content/:key/:id with meta auth "verified" and title "Content", per docs/design/screens/entry.md group "6 Entry editor", states 1 to 13 + And the structural prep of this slice is done first: ContentEntries is presentational with one owner of the list-view state, useContentTypes is split into entry and user-lookup parts, and one field-type registry (lib/fieldTypes) serves the schema builder, the list cells and this form + And the page requests GET /v1/content-type/{key} (the type) and, on /content/:key/:id, GET /v1/content-type/{key}/entry/{id} at the same time + And an entry is {id, content_type, data, created_by, updated_by, created_at, updated_at}, with content_type the key string + And POST /v1/content-type/{key}/entry answers 201 with the entry, PUT .../entry/{id} answers 200 with the entry and DELETE answers 204 with no body + And POST and PUT take {"data": {...}}; PUT replaces the whole entry and keeps created_by and created_at + And the API refuses with 404 content_type_not_found, 404 entry_not_found, 400 for an id that is not a uint32, 401, 403, 409 "invalid_entry: : unique" and 422 "invalid_entry: : ", and the body names only one field per answer + And any verified user may read and write entries; there is no owner or admin gate in the SPA + And a "/content/:key/:id" whose id is not made of digits or does not fit a uint32 shows "Entry not found" with no entry request, and "new" is the literal route + And the form is generated from the type's fields in schema order through the fieldTypes registry, with the six types text, richtext, number, boolean, date and select + And the data sent holds exactly one key per field of the loaded type, with the value or null for an empty optional field, so no default is applied by the server to a field the user cleared and no unknown key is ever sent + And a text or richtext value is counted in characters (code points) like the server, and is not trimmed + And a required field is empty when it is null, undefined or an empty string; false and 0 are values + And validation is done by the client against the loaded type before any request (required, type, min, max, options), one rule per field at a time in that order, and the server's single answer is mapped to its field when it gets past the client + And "Created by" and "Updated by" names come from GET /v1/user/lookup?ids= sent once after the entry answers, as in features/content_entry_list.feature; a missing name reads "–" with the date kept + And dates in the footer are "12 Oct 2026, 09:14" (local time zone, 24 h) + And the leave guard is useUnsavedChangesGuard, the same "Leave without saving?" dialog as features/schema_builder_edit.feature + And the status chip is the builder's: "Saved", "Saved at HH:MM" (never "N min ago"), "Unsaved changes", "Saving…", "Save failed", plus "New entry" and "Not saved" + And the SPA adds no beforeunload prompt, as nothing else in web/src does + And Markdown is rendered with markdown-it with raw HTML disabled and only http, https and mailto links active + And every string goes through t() and has a French entry in i18n/messages.ts + And a copy tagged "(proposed copy)" is not in the design and is the coder's single point of change + + Scenario: 1 New entry + Given GET /v1/content-type/events resolves 200 with the 7 fields Title, Starts on, Ends on, Online, Capacity, Category, Description + When the user navigates to /content/events/new + Then the breadcrumb reads "Content", "Events", "New entry" and the title row reads "New entry" with the subtitle "Events · Fields marked * are required." + And no entry request is sent + And the chip reads "New entry", "Cancel" and "Save" are shown, and "Delete" is not + And "Save" is enabled although nothing was typed + And each required label carries " *" + And the control of Online is Off and carries the tag "Default" + And the help under Title reads "Each entry needs a different title.", under Capacity "Between 1 and 5000.", the placeholders read "Choose an option" (Category) and "Pick a date" (Starts on, Ends on) + And the footer reads "Entry" "Not saved yet", "Created by" "–", "Updated by" "–" + And on mobile the topbar reads "New entry" with an arrow-left and no action, the chip sits under the title row, and the bottom bar holds "Cancel" and "Save" + + Scenario Outline: Defaults on a new entry + Given the type has a field with default + When the user opens /content/events/new + Then the control holds and the tag "Default" shows next to the label + When the user changes the value + Then the tag goes + And when the user restores the default value the tag shows again + + Examples: + | field | default | shown | + | Online (boolean) | false | Off | + | Capacity (number) | 100 | 100 | + | Category (select) | Talk | Talk | + | Starts on (date) | 2026-11-21 | 21 Nov 2026 | + | Title (text) | Untitled | Untitled | + | Description (richtext) | none | an empty textarea, no tag | + + Scenario: Defaults are shown only on create + When the user opens /content/events/42 + Then no field carries the tag "Default" and the values are those of the entry + + Scenario: 2 Editing + Given GET .../entry/42 resolves 200 with the entry "Lime plaster workshop" created by 3 on 2026-10-12 09:14 and updated by 5 on 2026-10-15 11:02, and the lookup names 3 "Inès Duarte" and 5 "Marta Alves" + When the user navigates to /content/events/42 + Then the breadcrumb reads "Content", "Events", "Lime plaster workshop" and the title reads "Lime plaster workshop" with the subtitle "Events · Entry 42" + And the chip reads "Saved" + And "Delete", "Cancel" and "Save" are shown, "Save" is disabled + And the controls hold the entry's values + And the footer reads "Entry" "events / 42" (mono), "Created by" "Inès Duarte · 12 Oct 2026, 09:14", "Updated by" "Marta Alves · 15 Oct 2026, 11:02" + And on mobile the topbar reads the title with an arrow-left and a trash-2 action, the chip sits under the title row, and the bottom bar holds "Cancel" and "Save" (disabled) + + Scenario Outline: The page title + Given the entry 42 whose first field holds + When the user opens /content/events/42 + Then the title and the last breadcrumb read + + Examples: + | first | value | title | + | Title (text) | "Lime plaster workshop" | Lime plaster workshop | + | Title (text) | null | Entry 42 | + | Body (richtext) | "First line\n\nSecond" | First line | + | Seats (number) | 18 | 18 | + + Scenario: The title does not change while typing + Given /content/events/42 + When the user types in Title + Then the title row and the breadcrumb keep "Lime plaster workshop" until a save answers + + Scenario Outline: Form controls per field type + Given a field of type <type> + Then the control is <control> with <detail> + + Examples: + | type | control | detail | + | text | UInput | a counter "n / max" when max is set | + | richtext | MarkdownField | tabs "Write" and "Preview", the toolbar bold, italic, heading, link, list, the placeholder "Write in Markdown", the help "Markdown. Switch to Preview to check the result." and a counter when max is set | + | number | UInputNumber | the placeholder "Enter a number" and minus and plus buttons; decimals are accepted | + | boolean | USwitch | the text "On" or "Off" next to it | + | date | UInputDate | the placeholder "Pick a date" and a calendar; the value is "YYYY-MM-DD" with no time-zone shift | + | select | USelect | the placeholder "Choose an option", the choices in schema order, and for an optional field a first item "None" (proposed copy) that clears it | + + Scenario Outline: Help lines + Given a field <field> + Then the help under it reads <help>, and is replaced by the error when there is one + + Examples: + | field | help | + | text, unique | Each entry needs a different title. (the label in lower case) | + | number, min 1, max 5000 | Between 1 and 5000. | + | number, min 1 | At least 1. (proposed copy) | + | number, max 5000 | At most 5000. (proposed copy) | + | date, min 2026-01-01, max 2027-12-31 | From 1 Jan 2026 until 31 Dec 2027. (proposed copy) | + | richtext | Markdown. Switch to Preview to check the result. | + | text, no unique, no number or date bounds | no help line | + + Scenario: Layout of the generated form + Given the 7 fields of Events on a 1440 px viewport + Then Title is full width, Starts on and Ends on share a row, Online and Capacity share a row, Category and Description are each full width, in a column of at most 760 px + And a date, number or boolean field pairs with the next one if that one is also a date, number or boolean, text, richtext and select are always full width + And below md every field is full width + + Scenario: 3 Unsaved changes and the counter + Given /content/events/42 with Title "Lime plaster workshop" (21 characters, max 80) + When the user types ", two days" in Title + Then the counter beside the label reads "32 / 80" in mono, muted + And the chip reads "Unsaved changes" and "Save" is enabled + And a counter over the limit reads "91 / 80" in the error colour, and typing is not blocked + And a counter shows only for a text or richtext field with a max + + Scenario: An edit that is reverted is not an unsaved change + Given the user changed Capacity from 18 to 20 + When the user sets it back to 18 + Then the chip reads "Saved", "Save" is disabled and the leave guard does not prompt + + Scenario: Cancel + Given /content/events/42, opened from the list /content/events?page=2&sort=id:asc + When the user activates "Cancel" + Then the router returns to /content/events?page=2&sort=id:asc when the previous history entry is the list of this type, else it navigates to /content/events + And the leave guard runs first when there are unsaved changes + + Scenario: 4 Markdown preview + Given a richtext field holding "Two days of hands-on work with **lime putty** and old stone.\n\n- Bring work clothes\n- Lunch is included" + When the user activates the "Preview" tab + Then the textarea and the toolbar are replaced by the rendered text with a bold "lime putty" and a bulleted list of two items, in a box at least 96 px high (120 px on mobile) + And raw HTML in the value is shown as text, never rendered, and only http, https and mailto links are active + And the tab state is not an edit, and switching back to "Write" restores the textarea with its value, caret kept + And an empty value in Preview reads "Nothing to preview yet." (proposed copy) + + Scenario Outline: The Markdown toolbar + Given the Write tab with the selection <selection> + When the user activates <button> + Then the textarea holds <result> and keeps the focus + + Examples: + | selection | button | result | + | "lime" | Bold | **lime** | + | "lime" | Italic | _lime_ | + | "lime" | Heading | ## lime | + | "lime" | Link | [lime](url) | + | "lime" | List | - lime | + + Scenario: Payload of a create + Given /content/events/new with Title "Open day", Starts on 2026-11-21, Online left at its default false, Ends on, Capacity, Category and Description left empty + When the user activates "Save" + Then POST /v1/content-type/events/entry is sent once with {"data": {"title": "Open day", "starts_on": "2026-11-21", "ends_on": null, "online": false, "capacity": null, "category": null, "description": null}} + And a number is sent as a JSON number, a boolean as true or false, a date as "YYYY-MM-DD", a select as the choice text + + Scenario: Payload of an edit + Given /content/events/42 with only Capacity changed from 18 to 20 + When the user activates "Save" + Then PUT /v1/content-type/events/entry/42 is sent once with every field of the type, the unchanged ones with their loaded values + And a key that the entry holds but the type no longer has is not sent + + Scenario Outline: A boolean that was never set + Given a boolean field <kind> and the user never touched it + When the entry is saved + Then the data holds <sent> for it + + Examples: + | kind | sent | + | optional, no default | null | + | optional, loaded as null | null | + | required, no default | false | + | optional, default true | true | + + Scenario: 5 Saving + Given the PUT has not answered + Then the chip reads "Saving…" with a loader, "Save" reads "Saving…" with a loader and is disabled, "Cancel" and "Delete" are disabled + And the form is dimmed to 70 % and every control is disabled + And a second activation of "Save", or Enter in a field, sends no second request + + Scenario: 6 Saved (edit) + Given /content/events/42 with Title changed to "Lime plaster workshop, two days" + When the PUT answers 200 with updated_by 5 and updated_at 2026-10-19 11:02 local + Then a success toast "Entry saved" with "\"Lime plaster workshop, two days\" is up to date." is shown + And the chip reads "Saved at 11:02", "Save" is disabled and the controls hold the answered values + And the title, the breadcrumb and the footer "Updated by" read the answered entry ("Marta Alves · 19 Oct 2026, 11:02") + And no "Leave without saving?" prompt is shown afterwards + And a later edit returns the chip to "Unsaved changes" + + Scenario: Saved (create) + Given /content/events/new with the required fields filled + When the POST answers 201 with id 43 + Then the router replaces the URL with /content/events/43 and no entry request is made for it, the answered entry is used + And the same toast, the chip "Saved at HH:MM" and the footer ("Entry" "events / 43", "Created by" and "Updated by" the current user) show + And "Delete" now shows and a Back from there goes to the page before the new form + And no "Leave without saving?" prompt is shown + + Scenario: Where "Saved at" comes from + Given the answered entry has updated_at 2026-10-19T11:02:00 in the local time zone + Then the chip text is "Saved at 11:02" built from that timestamp, never from the clock + And a Saved chip never turns into "Saved N min ago" + And a test builds its expected text with the same local formatter from the mocked updated_at and pins the time zone; no test freezes the system date + + Scenario Outline: 7 Save failed + Given the save <failure> + Then an error alert titled "Couldn't save the entry" shows "Something went wrong on our side. Your changes are still here. Try again in a moment." with a "Try again" action + And the chip reads "Save failed" in the error colour, the values stay, "Save" is enabled and the form is not dimmed + When the user activates "Try again" + Then the same body is sent again and the alert clears while it is in flight + + Examples: + | failure | + | answers 500 | + | never reaches the server | + | answers 400 | + | answers 422 "invalid_entry" with no field | + + Scenario: 8 Required fields empty + Given /content/events/new with Title and Starts on empty + When the user activates "Save" + Then no request is sent + And "Fill in Title." shows under Title and "Pick a date for Starts on." under Starts on, each with the error colour on the control + And the chip reads "Not saved" in the error colour with a circle-alert icon + And focus moves to the Title control, the first field in schema order with an error, and the page scrolls to it + And the controls carry aria-invalid and the message is tied to them by aria-describedby + + Scenario Outline: Required messages + Given an empty required field of type <type> labelled <label> + When the user saves + Then the message under it reads <message> + + Examples: + | type | label | message | + | text | Title | Fill in Title. | + | richtext | Notes | Fill in Notes. (proposed copy) | + | number | Capacity | Fill in Capacity. (proposed copy)| + | date | Starts on | Pick a date for Starts on. | + | select | Category | Choose an option for Category. | + + Scenario: A required boolean refused by the server + Given /content/events/42 where Online is a required boolean the user never touched + When the server answers 422 "invalid_entry: online: required" + Then the message under Online reads "Choose yes or no for Online." + And a required boolean is never refused on the client: an untouched one is sent as false + + Scenario: 9 Rule errors + Given /content/events/42 with Title of 91 characters, Ends on holding a value the server refuses as a date, Capacity 6000 and Category "Seminar" which is not a choice + When the user activates "Save" + Then only one message shows per field: "Title can be up to 80 characters." with the counter "91 / 80", "Ends on must be a date, like 2026-11-21.", "Capacity must be at most 5000.", "Choose one of: Talk, Workshop or Meetup." + And the chip reads "Not saved" and focus is on Title + + Scenario Outline: Messages per rule and type + Given a field <field> and the value <value> + When the entry is saved + Then the message under it reads <message> + + Examples: + | field | value | message | + | Title, text, min 3 | "ab" | Title needs at least 3 characters. | + | Title, text, max 80 | 91 characters | Title can be up to 80 characters. | + | Title, text | 5 (server type) | Title must be text. | + | Capacity, number, min 1 | 0 | Capacity must be at least 1. | + | Capacity, number, max 5000 | 6000 | Capacity must be at most 5000. | + | Capacity, number | "4" (server type) | Capacity must be a number. | + | Online, boolean | "yes" (server type) | Online must be yes or no. | + | Starts on, date, min 2026-01-01 | 2025-12-31 | Starts on can't be before 1 Jan 2026. | + | Starts on, date, max 2027-12-31 | 2028-01-01 | Starts on can't be after 31 Dec 2027. | + | Starts on, date | 2026-02-30 (server type)| Starts on must be a date, like 2026-11-21. | + | Category, select | "Seminar" | Choose one of: Talk, Workshop or Meetup. | + + Scenario Outline: A server answer that gets past the client + Given the client found no error and the request is sent + When the server answers <status> "invalid_entry: <key>: <rule>" for a key of the loaded type + Then the message of the table above shows under the field of <key>, the chip reads "Not saved", focus moves to that field and the values stay + + Examples: + | status | key | rule | + | 422 | title | min | + | 422 | capacity | max | + | 422 | ends_on | type | + | 422 | category | options | + | 422 | title | required| + + Scenario: Errors clear field by field + Given the errors of state 8 are shown + When the user edits Title + Then the message under Title goes and the chip reads "Unsaved changes" + And the message under Starts on stays until Starts on is edited or the next save + And nothing is validated on blur + + Scenario: 10 Duplicate unique value (409) + Given /content/events/new with Title "Heat pumps: what the numbers say" and another entry holds it + When the POST answers 409 "invalid_entry: title: unique" + Then an error alert titled "Not saved" shows "Another entry already uses this title. Change it and save again." + And under Title "Another entry already uses this Title. Enter a different one." shows, with the counter "n / 80", and the chip reads "Not saved" + And focus moves to Title and the values stay + And the alert goes when Title is edited + + Scenario Outline: The unique message per type + Given a unique <type> field labelled <label> + When the server answers 409 "invalid_entry: <key>: unique" + Then the message under it reads <message> and the alert reads <alert> + + Examples: + | type | label | key | message | alert | + | text | Title | title | Another entry already uses this Title. Enter a different one. | Another entry already uses this title. Change it and save again. | + | select | Category | category | Another entry already uses this choice for Category. | Another entry already uses this choice. Change it and save again. (proposed copy) | + | number | Capacity | capacity | Another entry already uses this Capacity. Enter a different one. (proposed copy) | Another entry already uses this capacity. Change it and save again. (proposed copy) | + + Scenario Outline: 11 Entry not found + Given <where> + Then a centered state with the file-x icon reads "Entry not found" with "It may have been deleted by someone else." and a primary "Back to Events" + And the breadcrumb reads "Content" then "Events" and the mobile title is "Events" + And "Back to Events" navigates to /content/events + And the leave guard does not prompt on that navigation + + Examples: + | where | + | GET .../entry/999 answers 404 entry_not_found | + | a PUT answers 404 entry_not_found (edits discarded with the page) | + | the URL is /content/events/abc or /content/events/4294967296 (no request) | + + Scenario Outline: Other answers while loading or saving + Given any request of the editor + When it answers <status> <body> + Then <result> + + Examples: + | status | body | result | + | 404 | content_type_not_found | the page "Content type not found" of features/content_entry_list.feature state 9 | + | 403 | any | the page "You don't have access to Events" of features/content_entry_list.feature state 8 | + | 401 | any | the existing redirect to /login with the expired reason | + + Scenario Outline: Loading the entry fails (proposed, not drawn) + Given GET .../entry/42 or the type <failure> + Then an error alert reads "Couldn't load the entry" (proposed copy) with "GET /v1/content-type/events/entry/42 failed. Check your connection and try again." and a "Try again" action + And no form shows and "Try again" sends only the failed request again + + Examples: + | failure | + | answers 500 | + | never reaches the server | + + Scenario: Loading (proposed, not drawn) + Given the type and the entry have not resolved + Then the title row shows a skeleton, the form is skeleton rows with aria-busy="true", and "Save" and "Delete" are not shown + + Scenario: 12 Schema changed since load + Given /content/events/new was opened with the 7 fields, then Room (required) was added and Description removed + When the POST answers 422 "invalid_entry: room: required" + Then a warning alert titled "The fields of Events changed" shows "A field was removed and a new required field, Room, was added after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields." with a "Reload form" action + And every control is disabled, the form is dimmed, "Save" is disabled + And the values stay visible + And the chip reads "Not saved" (the board draws "Saved" here) and "Cancel" stays enabled + + Scenario Outline: What counts as a schema change + Given the editor holds the type as loaded + When the server answers 422 "invalid_entry: <key>: <rule>" + Then <result> + + Examples: + | key | rule | result | + | room | required | the key is not a field of the loaded type: state 12 | + | description | unknown_field | state 12 | + | colour | unknown_field | state 12 | + | title | required | state 8 on Title (the key is known) | + | room | unique | state 12 (409) | + + Scenario: The alert names what changed only when the SPA can tell + Given the type is fetched again by "Reload form" + Then the alert copy of the design is used as is; the SPA never builds a diff of fields, since the design text names Room and the removed field only as a sample + + Scenario: Reload form + Given state 12 + When the user activates "Reload form" + Then GET /v1/content-type/events is sent again and, on /content/:key/:id, GET .../entry/42 too + And the form is rebuilt from the new type with defaults on a new entry, or the stored values on an edit; what was typed is dropped + And the alert goes, the chip reads "New entry" or "Saved", and the URL is unchanged + And a failure of the reload shows the load error of this slice + + Scenario: 13 Delete entry: confirm + Given /content/events/42 with no unsaved changes + When the user activates "Delete" (the trash-2 action on mobile) + Then no request is sent + And the dialog "Delete this entry?" opens with "\"Lime plaster workshop\" will be deleted from Events. This can't be undone." (a bottom sheet on mobile) + And the actions are "Cancel" and "Delete entry" (error colour, never the initial focus) + And Cancel, the close icon and Esc close it with nothing sent and focus back on "Delete" + + Scenario: Deleting + Given the dialog is open + When the user activates "Delete entry" + Then DELETE /v1/content-type/events/entry/42 is sent once + And the button reads "Deleting…" with a loader, Cancel is disabled, Esc, the close icon and the overlay do not dismiss, and a second activation sends nothing + + Scenario: Deleted + Given DELETE answers 204 + Then the dialog closes and the router replaces the URL with /content/events with no "Leave without saving?" prompt + And a success toast "Entry deleted" with "\"Lime plaster workshop\" was deleted from Events." (proposed copy) is shown + And the list loads its own data, so the entry is gone from it + + Scenario Outline: Other answers of DELETE + Given the dialog is open and DELETE <outcome> + Then <result> + + Examples: + | outcome | result | + | answers 404 entry_not_found | the dialog closes and the page "Entry not found" shows | + | answers 404 content_type_not_found| the dialog closes and the page "Content type not found" shows | + | answers 403 | the dialog closes and the page "You don't have access to Events" shows | + | answers 401 | the existing redirect to /login with the expired reason | + | answers 500 or fails (network) | the dialog stays with an error alert "Couldn't delete the entry" (proposed copy) and "Something went wrong on our side. Nothing was deleted. Try again in a moment."; the buttons are enabled again | + + Scenario: Delete with unsaved edits + Given /content/events/42 with unsaved edits + When the user opens the delete dialog + Then its copy uses the saved title, it does not save or discard the edits, and Cancel leaves them exactly as they were + And on 204 the router leaves with no leave prompt + + Scenario Outline: Leaving with unsaved edits + Given <state> + When the user leaves through the breadcrumb, the mobile arrow, "Cancel", the sidebar or the browser back + Then <result> + + Examples: + | state | result | + | no edit | the router navigates with no prompt | + | an edit on /content/events/42 | the dialog "Leave without saving?" with "Keep editing" and "Discard changes" | + | any typed value on /content/events/new | the same dialog | + | /content/events/new untouched, defaults only | no prompt | + | after Save answered 200 or 201 | no prompt | + | after Delete answered 204 | no prompt | + | state 12 with typed values | the same dialog | + + Scenario: Switching to another entry or type reuses nothing + Given the user edited /content/events/42 and discarded the edits to leave + When the user opens /content/events/43 or /content/recipes/new + Then the form is built from that route alone, with no value, error or chip carried over + + Scenario: Stale answers are ignored + Given a GET for /content/events/42 is in flight + When the user navigates to /content/events/43 and the first answer arrives later + Then it is dropped + + Scenario: Reaching the editor from the list + Given the list of features/content_entry_list.feature + When the user activates "New entry" or a row + Then the editor opens at /content/events/new or /content/events/42, no longer the catch-all 404 + + Scenario: Accessibility + Then every control has a visible label tied to it, the required mark " *" is hidden from assistive technology and the control carries aria-required + And the chip is a polite live region + And the Preview tabs are a tablist with arrow-key movement, and the toolbar buttons have accessible names (Bold, Italic, Heading, Link, List) + And dialogs trap focus, open with focus on the title and return it to the opener on close; the destructive button is never the initial focus + And controls are 44 px high on mobile and focus is a 2 px --ui-primary outline with a 2 px offset diff --git a/features/content_entry_editor.qa.md b/features/content_entry_editor.qa.md new file mode 100644 index 0000000..edb5f61 --- /dev/null +++ b/features/content_entry_editor.qa.md @@ -0,0 +1,69 @@ +# QA: Entry editor of a custom content type (SPA slice 7) + +Runner: Vitest + Vue Test Utils (`task test:web`). No Go test: the five entry endpoints and /v1/user/lookup already exist and the backend does not change. Kit: reuse `views/__tests__/schemaTestKit.ts` and add GET/POST/PUT/DELETE handlers for `/v1/content-type/{key}/entry[/{id}]` with the plain-text bodies `entry_not_found`, `content_type_not_found`, `invalid_entry: <key>: <rule>`, and the Events type with its 7 fields and entry 42 of SchemaFrame.dc.html. Pin the time zone. Never freeze the system date: "Saved at" is derived from the mocked `updated_at`, so the expected text is built with the same local formatter from that value. + +## Automated + +1. Registry (lib/fieldTypes spec, it.each over the six types): empty value, default pre-fill, control component, help line, counter rule, validate (required, type, min, max, options), message text, payload value (null for empty, false for a required boolean). The builder and the list cells still pass on the same registry. +2. Entry API (composable spec): getEntry, createEntry, saveEntry, deleteEntry; the body is {data}; error parsing of "invalid_entry: key: rule" with status 422 and 409 into {key, rule, status}; 404 bodies; 400, 401, 403 and NetworkError pass through; lookupUsers is sent once with distinct ids. +3. Route resolution (it.each): "new", "42", "abc", "0" (resolves to id 0, sent as is: the API answers 404 entry_not_found), "4294967296", "-1". +4. State 1 New entry: title, subtitle, chip "New entry", asterisks, Default tag on Online, helps, placeholders, footer "Not saved yet" and "–", Save enabled, Delete absent; mobile layout. +5. Defaults (it.each from the feature table): value shown, tag shown, tag leaves on change and returns on restore; no tag in edit. +6. State 2 Editing: breadcrumb, title, "Entry 42", chip "Saved", Save disabled, footer names and dates after the lookup; "–" with the date kept when the lookup fails or lacks an id; mobile trash-2 action. +7. Page title (it.each): text, null → "Entry 42", richtext first line, number; unchanged while typing. +8. Controls and help lines (it.each): one control per type, placeholders, Off/On text, empty select "None" item on optional select only. +9. Layout rule (pairing of date, number, boolean; full width for text, richtext, select; single column below md). +10. State 3 Unsaved: counter "32 / 80", "91 / 80" in the error colour without blocking, no counter without max, chip, Save enabled, revert returns to clean. +11. State 4 Preview: rendering of bold and list, raw HTML shown as text, unsafe links inert, empty preview, tab switch keeps value and caret, toolbar inserts (it.each). +12. Payloads: create and edit bodies exactly as in the feature, explicit nulls, no unknown key, boolean table. +13. State 5 Saving: chip, button, dimming, disabled controls, no second request on click or Enter. +14. State 6 Saved: edit and create; toast; chip "Saved at HH:MM" from the answer; footer update; create replaces the URL to /content/events/43 with no extra GET; no leave prompt; edit after save is dirty again. +15. State 7 Save failed (500, network, 400, 422 with no field): alert copy, chip, values kept, Try again resends the same body. +16. State 8 Required: no request, all messages, chip "Not saved", `document.activeElement` is the first erroring control in schema order for each type (input, textarea, select trigger, date segment), aria-invalid and aria-describedby, per-type required messages (it.each). +17. State 9 Rules: one message per field, message table (it.each), server answers that pass the client map to their field and take focus. +18. Errors clear per field; chip returns to "Unsaved changes"; no blur validation. +19. State 10 Unique (409): alert and field message, per-type messages, alert clears when the field is edited. +20. State 11 Not found: GET 404, PUT 404, DELETE 404 and bad ids with no request; "Back to Events" goes to /content/events with no prompt. +21. Content type 404, 403 and 401; load failure variants with "Try again" resending only the failed request; loading skeleton with aria-busy. +22. State 12 Schema changed: unknown key, unknown_field, required on an unknown key, 409 on an unknown key; read-only form; Save disabled; Cancel enabled; chip "Not saved"; "Reload form" refetches the type (and the entry) and rebuilds the form; reload failure. +23. State 13 Delete: dialog copy, focus on Cancel, Cancel/Esc/close, no request; Deleting lock; 204 replaces the URL and shows the toast; 404, 403, 401, 500 and network outcomes; delete with unsaved edits. +24. Leave guard (it.each from the feature table), including defaults-only new form untouched, and Cancel history rule (previous entry is the list vs not). +25. Stale answers dropped; navigating between routes carries nothing. +26. Reaching the editor from the list: "New entry" and row links resolve to the editor (update the slice-6 specs that expected the catch-all 404). The slice-6 Background wording about `content_type_id` and `sort` is checked against the Go handler and corrected with the list code and tests if it differs. +27. Accessibility: labels, aria-required, live-region chip, tablist keyboard, toolbar names, dialog focus. +28. French: every new string has an entry (i18n completeness spec passes). + +## Visual (task visual:check -- entry) + +Register the group "6 Entry editor" in `extraSources` if needed. Every capture within 10 %, desktop and mobile, light and dark. Mock the sidebar's GET /v1/content-type and /v1/user/lookup. No state is clock-sensitive: "Saved at" comes from the response timestamp. If the runner cannot pin the zone, build `updated_at` as the builder's Saved setup does. + +| Board state | Setup in web/visual/states.ts | Board sample data | +|---|---|---| +| 1 New entry | /content/events/new, Events type | "New entry", "Not saved yet", Online Off + "Default" | +| 2 Editing | /content/events/42, lookup 3 and 5 | "Lime plaster workshop", "Entry 42", "Inès Duarte · 12 Oct 2026, 09:14", "Marta Alves · 15 Oct 2026, 11:02" | +| 3 Unsaved changes | step: type ", two days" in Title | "Lime plaster workshop, two days", "32 / 80", focus ring on Title | +| 4 Markdown preview | step: click "Preview" on Description | "Two days of hands-on work with lime putty and old stone." and the two bullets | +| 5 Saving | PUT `hang: true` after an edit | "Saving…" | +| 6 Saved | PUT 200 with updated_at 2026-10-19 11:02 local, updated_by 5 | "Saved at 11:02", "Entry saved", "Marta Alves · 19 Oct 2026, 11:02" | +| 7 Save failed | PUT 500 after an edit | "Couldn't save the entry" | +| 8 Required fields empty | /content/events/new, click Save | "Fill in Title.", "Pick a date for Starts on." | +| 9 Rule errors | /content/events/42 with the board's values typed, PUT 422 for any server-side one | "91 / 80", "Capacity must be at most 5000.", "Choose one of: Talk, Workshop or Meetup." | +| 10 Duplicate (409) | /content/events/new, title "Heat pumps: what the numbers say", POST 409 | "Another entry already uses this Title. Enter a different one." | +| 11 Entry not found | /content/events/999, entry 404 entry_not_found | "Entry not found" | +| 12 Schema changed | /content/events/new, POST 422 invalid_entry: room: required | "The fields of Events changed" | +| 13 Delete entry: confirm | /content/events/42, step: click "Delete" | "Delete this entry?" | + +Board caveats to report, never hide: +- State 9 draws `31/11/2026` as text in Ends on; a segmented date control cannot hold it, so that field will differ. +- State 11's breadcrumb shows the entry title, unknown on a 404. +- State 12's chip is drawn "Saved" and its Cancel is drawn disabled; the spec uses "Not saved" and an enabled Cancel (user decision). +- Mobile in state 9 depends on the chosen layout rule. + +## Manual + +- Real backend with two users: create an entry; Back from it returns to the list, not the empty form; edit; delete; the list shows the change. +- Two tabs: change the type's fields in one (add a required field), save the entry in the other: state 12, "Reload form". Delete the entry in one tab and save in the other: state 11. +- Duplicate a unique title for a real 409. +- Keyboard: tab order across fields, toolbar, tabs, footer buttons; Enter in a single-line field saves; focus lands on the first error; dialogs trap focus. +- Markdown with raw HTML and `javascript:` links stays inert in Preview. +- Reload on /content/events/42 keeps the page; the browser back from a saved new entry goes to the page before the new form. diff --git a/features/content_entry_list.feature b/features/content_entry_list.feature index f19889a..f91981f 100644 --- a/features/content_entry_list.feature +++ b/features/content_entry_list.feature @@ -7,7 +7,7 @@ Feature: Entry list of a custom content type Given the SPA calls the API with relative URLs under /v1 and the "jwt" HttpOnly cookie And API errors are plain text And the route is /content/:key with meta auth "verified" and title "Content", per docs/design/screens/content.md group "5 Content and entry list", states 1 to 9 - And the page requests GET /v1/content-type/{key} (the type) and GET /v1/content-type/{key}/entry?page=P&size=S&sort=O (the entries) at the same time; the entries answer {items, total, page, size, sort} and each item is {id, content_type_id, data, created_by, updated_by, created_at, updated_at} + And the page requests GET /v1/content-type/{key} (the type) and GET /v1/content-type/{key}/entry?page=P&size=S&sort=O (the entries) at the same time; the entries answer {items, total, page, size} (no sort) and each item is {id, content_type (the type key), data, created_by, updated_by, created_at, updated_at} And the sidebar Content group of features/schema_list_and_content_nav.feature is reused unchanged; this slice only checks it in the "Many types" state And page, size and sort live in the URL (/content/events?page=2&size=50&sort=created_at:desc), the defaults (page 1, size 25, sort updated_at:desc) are omitted from the URL, and the request always carries explicit page and size And size is one of 5, 25, 50, 100; sort is <id|created_at|updated_at>:<asc|desc>; an invalid value is replaced silently by the default in the URL and in the request, so the API never answers 400 here diff --git a/web/bun.lock b/web/bun.lock index e5cb362..2144094 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -15,6 +15,7 @@ "@tiptap/pm": "3.31.3", "@tiptap/vue-3": "3.31.3", "@vueuse/core": "14.4.0", + "markdown-it": "^15.0.3", "pinia": "^3.0.4", "tailwindcss": "^4.1.18", "vue": "^3.5.25", @@ -24,6 +25,7 @@ "@iconify-json/lucide": "^1.2.136", "@iconify/utils": "^3.1.7", "@tsconfig/node24": "^24.0.3", + "@types/markdown-it": "^14.2.0", "@types/node": "^24.10.1", "@vitejs/plugin-vue": "^6.0.2", "@vue/eslint-config-prettier": "^10.2.0", @@ -438,6 +440,12 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="], + + "@types/markdown-it": ["@types/markdown-it@14.2.0", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ=="], + + "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], + "@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="], "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], @@ -546,7 +554,7 @@ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "argparse": ["argparse@3.0.3", "", {}, "sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -672,7 +680,7 @@ "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], @@ -902,6 +910,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "linkify-it": ["linkify-it@6.1.0", "", { "dependencies": { "uc.micro": "^3.0.0" } }, "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw=="], + "linkifyjs": ["linkifyjs@4.3.3", "", {}, "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg=="], "local-pkg": ["local-pkg@1.2.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q=="], @@ -916,12 +926,16 @@ "magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="], + "markdown-it": ["markdown-it@15.0.3", "", { "dependencies": { "argparse": "^3.0.3", "entities": "^8.1.0", "linkify-it": "^6.1.0", "mdurl": "^2.1.0", "punycode.js": "^2.3.1", "uc.micro": "^3.0.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ=="], + "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "mdurl": ["mdurl@2.1.0", "", {}, "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg=="], + "memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="], "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], @@ -1074,6 +1088,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -1184,6 +1200,8 @@ "typescript-eslint": ["typescript-eslint@8.50.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.50.0", "@typescript-eslint/parser": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0", "@typescript-eslint/utils": "8.50.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A=="], + "uc.micro": ["uc.micro@3.0.0", "", {}, "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw=="], + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], @@ -1392,6 +1410,8 @@ "glob/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + "js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "jsdom/xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], "magic-regexp/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1406,6 +1426,8 @@ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "path-scurry/lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], "pkg-types/confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], diff --git a/web/package.json b/web/package.json index 2f5ea19..7d368d2 100644 --- a/web/package.json +++ b/web/package.json @@ -26,6 +26,7 @@ "@tiptap/pm": "3.31.3", "@tiptap/vue-3": "3.31.3", "@vueuse/core": "14.4.0", + "markdown-it": "^15.0.3", "pinia": "^3.0.4", "tailwindcss": "^4.1.18", "vue": "^3.5.25", @@ -35,6 +36,7 @@ "@iconify-json/lucide": "^1.2.136", "@iconify/utils": "^3.1.7", "@tsconfig/node24": "^24.0.3", + "@types/markdown-it": "^14.2.0", "@types/node": "^24.10.1", "@vitejs/plugin-vue": "^6.0.2", "@vue/eslint-config-prettier": "^10.2.0", diff --git a/web/src/components/BuilderBadge.vue b/web/src/components/BuilderBadge.vue index 00df844..fb8ad26 100644 --- a/web/src/components/BuilderBadge.vue +++ b/web/src/components/BuilderBadge.vue @@ -4,7 +4,7 @@ import { t } from '@/i18n' import { computed } from 'vue' const props = defineProps<{ - kind: 'new' | 'changed' | 'cause' | 'required' | 'unique' + kind: 'new' | 'changed' | 'cause' | 'required' | 'unique' | 'default' compact?: boolean }>() @@ -34,6 +34,12 @@ const badges = { }, class: 'border border-accented bg-transparent text-toned', }, + default: { + get label() { + return t('Default') + }, + class: 'border border-accented bg-transparent text-toned', + }, unique: { get label() { return t('Unique') diff --git a/web/src/components/BuilderStatusChip.vue b/web/src/components/BuilderStatusChip.vue index 3f55875..59cdd1e 100644 --- a/web/src/components/BuilderStatusChip.vue +++ b/web/src/components/BuilderStatusChip.vue @@ -4,30 +4,43 @@ import { t } from '@/i18n' import { computed } from 'vue' import type { BuilderStatus } from '@/lib/schemaBuilderModel' -const props = defineProps<{ - status: BuilderStatus - problems: number - savedAt?: string -}>() +const props = withDefaults( + defineProps<{ + status: BuilderStatus | 'new' | 'unsaved' + problems?: number + savedAt?: string + }>(), + { problems: 0, savedAt: undefined }, +) -const text = computed(() => { - switch (props.status) { +function chipText( + status: BuilderStatus | 'new' | 'unsaved', + problems: number, + savedAt: string | undefined, +): string { + switch (status) { case 'saving': return t('Saving…') case 'offline': return t('Waiting for connection') case 'saved': - return props.savedAt ? t('Saved at {0}', [props.savedAt]) : t('Saved') + return savedAt ? t('Saved at {0}', [savedAt]) : t('Saved') case 'failed': return t('Save failed') + case 'new': + return t('New entry') + case 'unsaved': + return t('Not saved') case 'problems': - return props.problems === 1 - ? t('1 problem to fix') - : t('{0} problems to fix', [props.problems]) - default: + return problems === 1 ? t('1 problem to fix') : t('{0} problems to fix', [problems]) + case 'dirty': return t('Unsaved changes') + case 'none': + return '' } -}) +} + +const text = computed(() => chipText(props.status, props.problems, props.savedAt)) </script> <template> @@ -38,8 +51,12 @@ const text = computed(() => { class="inline-flex shrink-0 items-center gap-1.5 text-xs whitespace-nowrap" :class="{ 'text-warning': status === 'dirty', - 'text-error': status === 'failed' || status === 'problems', - 'text-muted': status === 'saving' || status === 'offline' || status === 'saved', + 'text-error': status === 'failed' || status === 'problems' || status === 'unsaved', + 'text-muted': + status === 'saving' || + status === 'offline' || + status === 'saved' || + status === 'new', }" > <UIcon @@ -50,7 +67,7 @@ const text = computed(() => { <UIcon v-else-if="status === 'offline'" name="i-lucide-wifi-off" class="size-3.5" /> <UIcon v-else-if="status === 'dirty'" name="i-lucide-circle-dot" class="size-3.5" /> <UIcon v-else-if="status === 'saved'" name="i-lucide-check" class="size-3.5" /> - <UIcon v-else name="i-lucide-circle-alert" class="size-3.5" /> + <UIcon v-else-if="status !== 'new'" name="i-lucide-circle-alert" class="size-3.5" /> <span>{{ text }}</span> </span> </template> diff --git a/web/src/components/ColumnPickerList.vue b/web/src/components/ColumnPickerList.vue index 2a130c2..dc23831 100644 --- a/web/src/components/ColumnPickerList.vue +++ b/web/src/components/ColumnPickerList.vue @@ -1,7 +1,7 @@ <script setup lang="ts"> import { t } from '@/i18n' -import { fieldIcon } from '@/lib/schemaFields' +import { fieldTypeSpec } from '@/lib/fieldTypes' import { COLUMN_CAP } from '@/lib/entryColumns' import type { BuiltinField } from '@/lib/contentTypes' @@ -43,7 +43,10 @@ const ROW = 'flex items-center gap-2.5 px-2' @update:model-value="emit('toggle', field.key, $event === true)" > <template #label> - <UIcon :name="fieldIcon(field.type)" class="size-3.5 shrink-0 text-muted" /> + <UIcon + :name="fieldTypeSpec(field.type).icon" + class="size-3.5 shrink-0 text-muted" + /> <span class="min-w-0 flex-1 truncate">{{ field.label }}</span> <span class="font-mono text-xs text-muted">{{ field.key }}</span> </template> diff --git a/web/src/components/ContentEntries.vue b/web/src/components/ContentEntries.vue index 9184d3e..6e9bb4f 100644 --- a/web/src/components/ContentEntries.vue +++ b/web/src/components/ContentEntries.vue @@ -1,9 +1,7 @@ <script setup lang="ts"> import { t, formatNumber } from '@/i18n' -import { computed, onMounted, watch } from 'vue' -import { useRouter } from 'vue-router' -import { useMediaQuery } from '@vueuse/core' +import { computed } from 'vue' import AppPaginator from '@/components/AppPaginator.vue' import ArticleListNotice from '@/components/ArticleListNotice.vue' import ContentEmpty from '@/components/ContentEmpty.vue' @@ -14,94 +12,39 @@ import DirectoryLoadError from '@/components/DirectoryLoadError.vue' import EntryCardList from '@/components/EntryCardList.vue' import EntrySkeleton from '@/components/EntrySkeleton.vue' import EntryTable from '@/components/EntryTable.vue' -import type { ContentType, ContentTypeDetailStatus } from '@/lib/contentTypes' -import { useEntryColumns } from '@/composables/useEntryColumns' -import { useEntryList } from '@/composables/useEntryList' -import { resolveContentListState, type ContentListState } from '@/lib/contentListState' -import { entryCaption } from '@/lib/entryListQuery' -import { fieldCountLabel } from '@/lib/schemaRows' -import { HOME_PATH, entryPath, schemaPath } from '@/lib/routePaths' +import type { ContentListPage } from '@/composables/useContentListPage' +import { entriesLoadErrorLine, typeLoadErrorLine } from '@/lib/entryMessages' -const DESKTOP_QUERY = '(min-width: 48rem)' -const DESKTOP_SKELETON_ROWS = 8 -const MOBILE_SKELETON_ROWS = 6 +const props = defineProps<{ page: ContentListPage }>() -const props = defineProps<{ - typeKey: string - type: ContentType | null - typeStatus: ContentTypeDetailStatus - canEdit: boolean -}>() +const typeKey = props.page.typeKey +const { + list, + columns, + type, + typeStatus, + isDesktop, + view, + centered, + failed, + name, + fields, + subtitle, + editTo, + caption, + showSkeleton, + skeletonRows, + showBody, + createEntry, + goHome, + retry, +} = props.page -const emit = defineEmits<{ view: [view: ContentListState]; 'retry-type': [] }>() - -const router = useRouter() -const isDesktop = useMediaQuery(DESKTOP_QUERY) -const list = useEntryList(props.typeKey) -const columns = useEntryColumns(props.typeKey, () => props.type?.fields ?? []) - -const view = computed(() => - resolveContentListState({ - type: props.typeStatus, - entries: list.status.value, - total: list.total.value, - }), -) -const centered = computed(() => view.value === 'forbidden' || view.value === 'notFound') -const failed = computed(() => view.value === 'typeError' || view.value === 'entriesError') -const name = computed(() => props.type?.name ?? props.typeKey) -const fields = computed(() => props.type?.fields ?? []) -const subtitle = computed(() => - props.type ? `${props.type.key} · ${fieldCountLabel(props.type.fields.length)}` : '', -) -const editTo = computed(() => (props.canEdit ? schemaPath(props.typeKey) : undefined)) -const caption = computed(() => entryCaption(name.value, list.shown.value.sort)) - -const showSkeleton = computed(() => - view.value === 'loading' - ? list.showSkeleton.value || list.status.value === 'ready' - : view.value === 'list' && list.skeletonActive.value, -) -const skeletonRows = computed(() => { - if (view.value === 'list') { - return list.items.value.length - } - return isDesktop.value ? DESKTOP_SKELETON_ROWS : MOBILE_SKELETON_ROWS -}) -const showBody = computed(() => view.value === 'list' && !showSkeleton.value) const singlePageLabel = computed(() => list.total.value === 1 ? t('1 entry') : t('{0} entries', [formatNumber(list.total.value)]), ) -const typeErrorLine = computed(() => - t('GET /v1/content-type/{0} failed. Check your connection and try again.', [props.typeKey]), -) -const entriesErrorLine = computed(() => - t('GET /v1/content-type/{0}/entry failed. Check your connection and try again.', [ - props.typeKey, - ]), -) - -function createEntry() { - router.push(entryPath(props.typeKey, 'new')) -} - -function goHome() { - router.push(HOME_PATH) -} - -function retry() { - if (view.value === 'typeError') { - emit('retry-type') - return - } - list.retry() -} - -watch(view, (value) => emit('view', value), { immediate: true }) - -onMounted(() => { - list.start() -}) +const typeErrorLine = computed(() => typeLoadErrorLine(typeKey)) +const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey)) </script> <template> diff --git a/web/src/components/ContentState.vue b/web/src/components/ContentState.vue index 2c6ccab..6f4c744 100644 --- a/web/src/components/ContentState.vue +++ b/web/src/components/ContentState.vue @@ -3,11 +3,28 @@ import { t } from '@/i18n' import { computed } from 'vue' -const props = defineProps<{ kind: 'forbidden' | 'notFound'; name: string }>() +const props = defineProps<{ kind: 'forbidden' | 'notFound' | 'entryNotFound'; name: string }>() -const emit = defineEmits<{ home: [] }>() +const emit = defineEmits<{ home: []; back: [] }>() const forbidden = computed(() => props.kind === 'forbidden') +const entryMissing = computed(() => props.kind === 'entryNotFound') +const title = computed(() => { + if (forbidden.value) { + return t("You don't have access to {0}", [props.name]) + } + return entryMissing.value ? t('Entry not found') : t('Content type not found') +}) +const description = computed(() => { + if (forbidden.value) { + return t( + "Your account can't open this content right now. If you think this is a mistake, ask an owner or an admin.", + ) + } + return entryMissing.value + ? t('It may have been deleted by someone else.') + : t('It may have been deleted, or the address may be mistyped.') +}) </script> <template> @@ -15,16 +32,8 @@ const forbidden = computed(() => props.kind === 'forbidden') <UEmpty variant="naked" :icon="forbidden ? 'i-lucide-lock' : 'i-lucide-file-x'" - :title=" - forbidden ? t('You don\'t have access to {0}', [name]) : t('Content type not found') - " - :description=" - forbidden - ? t( - 'Your account can\'t open this content right now. If you think this is a mistake, ask an owner or an admin.', - ) - : t('It may have been deleted, or the address may be mistyped.') - " + :title="title" + :description="description" :ui="{ root: 'w-full max-w-105 gap-3 p-0 sm:p-0 lg:p-0', avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned', @@ -33,7 +42,10 @@ const forbidden = computed(() => props.kind === 'forbidden') }" > <template #actions> - <UButton class="h-8 px-4 max-md:h-11" @click="emit('home')"> + <UButton v-if="entryMissing" class="h-8 px-4 max-md:h-11" @click="emit('back')"> + {{ t('Back to {0}', [name]) }} + </UButton> + <UButton v-else class="h-8 px-4 max-md:h-11" @click="emit('home')"> {{ t('Go to Home') }} </UButton> </template> diff --git a/web/src/components/DashboardShell.vue b/web/src/components/DashboardShell.vue index 9b2b63b..2a4c991 100644 --- a/web/src/components/DashboardShell.vue +++ b/web/src/components/DashboardShell.vue @@ -51,12 +51,16 @@ const props = withDefaults( const COMPACT_BODY = 'gap-0 px-4 pt-4 pb-22 sm:gap-0 sm:px-4 sm:pt-4 sm:pb-22 lg:p-6' const DEFAULT_BODY = 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8' +const COMPACT_STICKY_BODY = 'gap-0 px-4 pt-4 pb-0 sm:gap-0 sm:px-4 sm:pt-4 sm:pb-0 lg:p-6' const STICKY_FOOTER_BODY = 'gap-0 px-4 pt-5 pb-0 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-0 lg:p-8' const bodyUi = computed(() => { if (props.fullBleed) { return 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0' } + if (props.stickyFooter && props.compactPadding) { + return COMPACT_STICKY_BODY + } if (props.stickyFooter) { return STICKY_FOOTER_BODY } diff --git a/web/src/components/EntryActions.vue b/web/src/components/EntryActions.vue new file mode 100644 index 0000000..ea91079 --- /dev/null +++ b/web/src/components/EntryActions.vue @@ -0,0 +1,58 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +import BuilderStatusChip from '@/components/BuilderStatusChip.vue' +import type { EntryEditorPage } from '@/composables/useEntryEditorPage' + +defineProps<{ page: EntryEditorPage; bar?: boolean }>() +</script> + +<template> + <div + class="flex shrink-0 items-center" + :class="bar ? 'w-full gap-2' : 'gap-3'" + :data-testid="bar ? 'entry-bar' : 'entry-actions'" + > + <BuilderStatusChip + v-if="!bar" + :status="page.chip.value" + :saved-at="page.savedClock.value" + /> + <UButton + v-if="!bar && !page.creating.value" + type="button" + color="error" + variant="outline" + icon="i-lucide-trash-2" + :disabled="page.saving.value" + class="h-8 px-3" + @click="page.removal.open" + > + {{ t('Delete') }} + </UButton> + <UButton + type="button" + color="neutral" + variant="outline" + :disabled="page.saving.value" + class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8" + :class="bar ? 'flex-1 justify-center' : ''" + @click="page.cancel" + > + {{ t('Cancel') }} + </UButton> + <UButton + type="submit" + form="entry-form" + :disabled="!page.canSave.value" + :loading="page.saving.value" + class="h-11 px-4 md:h-8" + :class="[ + bar ? 'flex-1 justify-center' : '', + page.saving.value ? 'disabled:opacity-100' : '', + ]" + > + {{ page.saving.value ? t('Saving…') : t('Save') }} + </UButton> + </div> +</template> diff --git a/web/src/components/EntryControl.vue b/web/src/components/EntryControl.vue new file mode 100644 index 0000000..1f1f59c --- /dev/null +++ b/web/src/components/EntryControl.vue @@ -0,0 +1,113 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +import { computed } from 'vue' +import EntryDateField from '@/components/EntryDateField.vue' +import MarkdownField from '@/components/MarkdownField.vue' +import AppSwitch from '@/components/AppSwitch.vue' +import type { BuiltinField } from '@/lib/contentTypes' +import { asText, freeSentinel } from '@/lib/entryValue' +import { fieldTypeSpec } from '@/lib/fieldTypes' + +const INPUT_UI = { + base: 'h-11 text-lg disabled:bg-elevated disabled:opacity-100 md:h-8 md:text-sm', +} +const NUMBER_UI = { + base: 'h-11 pe-20 text-left text-lg disabled:bg-elevated disabled:opacity-100 md:h-8 md:pe-14 md:text-sm', + decrement: 'start-auto end-10 border-s border-default p-0 md:end-7', + increment: 'end-0 border-s border-default p-0', +} +const STEP_BUTTON = { class: 'size-10 justify-center rounded-none text-muted md:size-7' } +const NUMBER_FORMAT = { maximumFractionDigits: 20, useGrouping: false } + +const props = defineProps<{ + field: BuiltinField + value: unknown + invalid: boolean + disabled: boolean +}>() + +const emit = defineEmits<{ update: [value: unknown] }>() + +const control = computed(() => fieldTypeSpec(props.field.type).editor.control) +const color = computed(() => (props.invalid ? 'error' : undefined)) +const required = computed(() => (props.field.required ? 'true' : undefined)) +const optionLabels = computed(() => (props.field.options ?? []).map(String)) +const noChoice = computed(() => freeSentinel(optionLabels.value)) +const choices = computed(() => { + const items = optionLabels.value.map((option) => ({ label: option, value: option })) + return props.field.required ? items : [{ label: t('None'), value: noChoice.value }, ...items] +}) + +function onChoice(value: unknown) { + emit('update', value === noChoice.value || value === undefined ? '' : String(value)) +} + +function onNumber(value: number | null | undefined) { + emit('update', value === undefined || Number.isNaN(value) ? null : value) +} +</script> + +<template> + <UInput + v-if="control === 'input'" + :model-value="asText(value)" + :color="color" + :disabled="disabled" + :aria-required="required" + class="w-full" + :ui="INPUT_UI" + @update:model-value="emit('update', String($event))" + /> + <MarkdownField + v-else-if="control === 'markdown'" + :model-value="asText(value)" + :invalid="invalid" + :disabled="disabled" + :required="field.required" + @update:model-value="emit('update', $event)" + /> + <UInputNumber + v-else-if="control === 'number'" + :model-value="typeof value === 'number' ? value : undefined" + :placeholder="t('Enter a number')" + :color="color" + :disabled="disabled" + :aria-required="required" + :format-options="NUMBER_FORMAT" + class="w-full" + :ui="NUMBER_UI" + :increment="STEP_BUTTON" + :decrement="STEP_BUTTON" + @update:model-value="onNumber" + /> + <div v-else-if="control === 'switch'" class="flex min-h-6 items-center gap-2.5"> + <AppSwitch + :model-value="value === true" + :disabled="disabled" + :aria-required="required" + @update:model-value="emit('update', $event)" + /> + <span class="text-toned">{{ value === true ? t('On') : t('Off') }}</span> + </div> + <EntryDateField + v-else-if="control === 'date'" + :model-value="asText(value)" + :invalid="invalid" + :disabled="disabled" + :required="field.required" + @update:model-value="emit('update', $event)" + /> + <USelect + v-else + :model-value="value === '' || value === null ? undefined : String(value)" + :items="choices" + :placeholder="t('Choose an option')" + :color="color" + :disabled="disabled" + :aria-required="required" + class="w-full" + :ui="INPUT_UI" + @update:model-value="onChoice" + /> +</template> diff --git a/web/src/components/EntryDateField.vue b/web/src/components/EntryDateField.vue new file mode 100644 index 0000000..8d1e441 --- /dev/null +++ b/web/src/components/EntryDateField.vue @@ -0,0 +1,86 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +import { computed, ref } from 'vue' +import { parseDate, type CalendarDate } from '@internationalized/date' + +const props = defineProps<{ + modelValue: string + disabled?: boolean + required?: boolean + invalid?: boolean +}>() + +const emit = defineEmits<{ 'update:modelValue': [value: string] }>() + +const focused = ref(false) +const calendarOpen = ref(false) + +function parse(value: string) { + try { + return parseDate(value) + } catch { + return undefined + } +} + +const date = computed(() => parse(props.modelValue)) +const idle = computed(() => !props.modelValue && !focused.value) + +function onDate(value: CalendarDate | null | undefined) { + emit('update:modelValue', value ? value.toString() : '') +} + +function onCalendar(value: unknown) { + onDate(value as CalendarDate | null) + calendarOpen.value = false +} +</script> + +<template> + <div class="relative w-full"> + <UInputDate + :model-value="date" + :disabled="disabled" + :color="invalid ? 'error' : undefined" + :aria-required="required ? 'true' : undefined" + class="w-full" + :ui="{ + base: 'h-11 w-full text-lg data-[disabled]:bg-elevated data-[disabled]:opacity-100 md:h-8 md:text-sm', + segment: idle ? 'text-transparent data-[placeholder]:text-transparent' : '', + }" + @update:model-value="onDate" + @focus="focused = true" + @blur="focused = false" + > + <template #trailing> + <UPopover v-model:open="calendarOpen" :content="{ align: 'end' }"> + <UButton + type="button" + color="neutral" + variant="link" + icon="i-lucide-calendar" + :disabled="disabled" + :aria-label="t('Open calendar')" + class="size-8 justify-center text-muted max-md:size-11" + :ui="{ leadingIcon: 'size-3.5' }" + /> + <template #content> + <UCalendar + :model-value="date" + class="p-2" + @update:model-value="onCalendar" + /> + </template> + </UPopover> + </template> + </UInputDate> + <span + v-if="idle" + aria-hidden="true" + class="pointer-events-none absolute inset-y-0 left-2.5 flex items-center text-lg text-muted md:text-sm" + > + {{ t('Pick a date') }} + </span> + </div> +</template> diff --git a/web/src/components/EntryDeleteDialog.vue b/web/src/components/EntryDeleteDialog.vue new file mode 100644 index 0000000..a388739 --- /dev/null +++ b/web/src/components/EntryDeleteDialog.vue @@ -0,0 +1,46 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +import { computed } from 'vue' +import DirectoryDialog from '@/components/DirectoryDialog.vue' + +const props = defineProps<{ + title: string + typeName: string + pending: boolean + failed: boolean +}>() + +const emit = defineEmits<{ confirm: []; close: [] }>() + +const notice = computed(() => + props.failed + ? { + title: t("Couldn't delete the entry"), + description: t( + 'Something went wrong on our side. Nothing was deleted. Try again in a moment.', + ), + } + : null, +) +const description = computed(() => + t('"{0}" will be deleted from {1}. This can\'t be undone.', [props.title, props.typeName]), +) +</script> + +<template> + <DirectoryDialog + :title="t('Delete this entry?')" + :description="description" + :confirm-label="t('Delete entry')" + :pending-label="t('Deleting…')" + :cancel-label="t('Cancel')" + tone="danger" + :pending="pending" + :notice="notice" + closable + close-gutter + @confirm="emit('confirm')" + @close="emit('close')" + /> +</template> diff --git a/web/src/components/EntryEditor.vue b/web/src/components/EntryEditor.vue new file mode 100644 index 0000000..6c9fa40 --- /dev/null +++ b/web/src/components/EntryEditor.vue @@ -0,0 +1,107 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +import BuilderStatusChip from '@/components/BuilderStatusChip.vue' +import ContentState from '@/components/ContentState.vue' +import DirectoryLoadError from '@/components/DirectoryLoadError.vue' +import EntryActions from '@/components/EntryActions.vue' +import EntryDeleteDialog from '@/components/EntryDeleteDialog.vue' +import EntryEditorSkeleton from '@/components/EntryEditorSkeleton.vue' +import EntryForm from '@/components/EntryForm.vue' +import EntryNotices from '@/components/EntryNotices.vue' +import type { EntryEditorPage } from '@/composables/useEntryEditorPage' +import { typeEndpoint } from '@/lib/contentEndpoints' +import { entryLoadErrorLine } from '@/lib/entryMessages' +import { HOME_PATH, contentPath } from '@/lib/routePaths' +import { useRouter } from 'vue-router' + +const props = defineProps<{ page: EntryEditorPage }>() + +const router = useRouter() +const { phase, isDesktop, typeKey } = props.page + +function loadErrorLine() { + const id = props.page.entryId.value + const base = typeEndpoint(typeKey) + return entryLoadErrorLine( + phase.value === 'entryError' && id !== null ? `${base}/entry/${id}` : base, + ) +} + +function goHome() { + router.push(HOME_PATH) +} + +function backToList() { + router.push(contentPath(typeKey)) +} +</script> + +<template> + <ContentState + v-if="phase === 'forbidden' || phase === 'typeNotFound'" + :kind="phase === 'forbidden' ? 'forbidden' : 'notFound'" + :name="page.typeName.value" + @home="goHome" + /> + <ContentState + v-else-if="phase === 'entryNotFound'" + kind="entryNotFound" + :name="page.typeName.value" + @back="backToList" + /> + + <div v-else class="flex flex-col gap-4" data-testid="entry-editor"> + <div class="flex flex-wrap items-end gap-4"> + <div class="flex min-w-50 flex-1 flex-col gap-0.5"> + <template v-if="phase === 'loading'"> + <USkeleton class="h-7 w-40 rounded-none bg-accented max-lg:hidden" /> + <USkeleton class="h-5 w-28 rounded-none bg-accented" /> + </template> + <template v-else> + <h1 class="text-xl font-semibold text-highlighted max-lg:sr-only"> + {{ page.title.value }} + </h1> + <p class="text-muted">{{ page.subtitle.value }}</p> + </template> + </div> + <EntryActions v-if="isDesktop && phase === 'ready'" :page="page" /> + </div> + + <div + v-if="!isDesktop && phase === 'ready'" + class="flex min-h-5 items-center justify-between" + > + <BuilderStatusChip :status="page.chip.value" :saved-at="page.savedClock.value" /> + </div> + + <DirectoryLoadError + v-if="phase === 'typeError' || phase === 'entryError'" + :title="t('Couldn\'t load the entry')" + :description="loadErrorLine()" + @retry="page.retryLoad" + /> + <EntryEditorSkeleton v-else-if="phase === 'loading'" /> + <template v-else> + <EntryNotices :page="page" /> + <EntryForm :page="page" /> + </template> + </div> + + <div + v-if="!isDesktop && phase === 'ready'" + class="sticky bottom-0 -mx-4 mt-auto flex border-t border-default bg-default px-4 py-3" + > + <EntryActions :page="page" bar /> + </div> + + <EntryDeleteDialog + v-if="page.removal.stage.value !== 'closed'" + :title="page.title.value" + :type-name="page.typeName.value" + :pending="page.removal.pending.value" + :failed="page.removal.stage.value === 'failed'" + @confirm="page.removal.confirm" + @close="page.removal.close" + /> +</template> diff --git a/web/src/components/EntryEditorSkeleton.vue b/web/src/components/EntryEditorSkeleton.vue new file mode 100644 index 0000000..7e2a089 --- /dev/null +++ b/web/src/components/EntryEditorSkeleton.vue @@ -0,0 +1,20 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +const ROWS = 5 +</script> + +<template> + <div + role="status" + aria-busy="true" + :aria-label="t('Loading')" + data-testid="entry-editor-skeleton" + class="flex max-w-190 flex-col gap-5 border border-default bg-default p-4 md:p-6" + > + <div v-for="row in ROWS" :key="row" class="flex flex-col gap-1.5"> + <USkeleton class="h-4 w-24 rounded-none bg-accented" /> + <USkeleton class="h-11 w-full rounded-none bg-accented md:h-8" /> + </div> + </div> +</template> diff --git a/web/src/components/EntryField.vue b/web/src/components/EntryField.vue new file mode 100644 index 0000000..36c7c72 --- /dev/null +++ b/web/src/components/EntryField.vue @@ -0,0 +1,54 @@ +<script setup lang="ts"> +import { computed } from 'vue' +import BuilderBadge from '@/components/BuilderBadge.vue' +import DirectoryField from '@/components/DirectoryField.vue' +import EntryControl from '@/components/EntryControl.vue' +import type { BuiltinField } from '@/lib/contentTypes' +import type { FieldIssue } from '@/lib/entryForm' +import { fieldTypeSpec } from '@/lib/fieldTypes' + +const props = defineProps<{ + field: BuiltinField + value: unknown + issue?: FieldIssue + isDefault: boolean + disabled: boolean +}>() + +const emit = defineEmits<{ update: [key: string, value: unknown] }>() + +const editor = computed(() => fieldTypeSpec(props.field.type).editor) +const counter = computed(() => editor.value.counter(props.field, props.value)) +const help = computed(() => editor.value.help(props.field)) +</script> + +<template> + <div :data-entry-field="field.key" class="min-w-0"> + <DirectoryField + :label="field.label" + :name="field.key" + :required="field.required" + :error="issue?.message" + :help="help" + > + <template v-if="counter || isDefault" #hint> + <span class="flex items-center gap-2"> + <span + v-if="counter" + data-testid="entry-counter" + :class="counter.over ? 'text-error' : ''" + >{{ counter.text }}</span + > + <BuilderBadge v-if="isDefault" kind="default" /> + </span> + </template> + <EntryControl + :field="field" + :value="value" + :invalid="Boolean(issue)" + :disabled="disabled" + @update="emit('update', field.key, $event)" + /> + </DirectoryField> + </div> +</template> diff --git a/web/src/components/EntryForm.vue b/web/src/components/EntryForm.vue new file mode 100644 index 0000000..2a56535 --- /dev/null +++ b/web/src/components/EntryForm.vue @@ -0,0 +1,75 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +import { computed, nextTick, watch } from 'vue' +import EntryField from '@/components/EntryField.vue' +import type { EntryEditorPage } from '@/composables/useEntryEditorPage' +import { groupFields } from '@/lib/entryLayout' + +const FOCUSABLE = + 'input:not([type="hidden"]), textarea, [role="spinbutton"], [role="switch"], [role="combobox"]' + +const props = defineProps<{ page: EntryEditorPage }>() + +const { form, fields, locked, focusToken } = props.page +const rows = computed(() => groupFields(fields.value)) +const dimmed = computed(() => locked.value) + +async function focusFirstIssue() { + await nextTick() + const key = form.firstIssue.value + if (!key) { + return + } + const wrapper = [...document.querySelectorAll<HTMLElement>('[data-entry-field]')].find( + (candidate) => candidate.dataset.entryField === key, + ) + const control = wrapper?.querySelector<HTMLElement>(FOCUSABLE) + control?.focus() + ;(wrapper ?? control)?.scrollIntoView?.({ block: 'center' }) +} + +watch(focusToken, focusFirstIssue) +</script> + +<template> + <form + id="entry-form" + novalidate + data-testid="entry-form" + class="flex max-w-190 flex-col gap-5 border border-default bg-default p-4 transition-opacity md:p-6" + :class="dimmed ? 'opacity-70' : ''" + :aria-busy="page.saving.value ? 'true' : undefined" + @submit.prevent="page.submit" + > + <div + v-for="row in rows" + :key="row.map((field) => field.key).join('+')" + class="grid gap-4" + :class="row.length > 1 ? 'md:grid-cols-2' : ''" + > + <EntryField + v-for="field in row" + :key="field.key" + :field="field" + :value="form.values.value[field.key]" + :issue="form.issues.value[field.key]" + :is-default="form.isDefault(field)" + :disabled="locked" + @update="page.change" + /> + </div> + + <dl + data-testid="entry-meta" + class="grid grid-cols-1 gap-x-4 gap-y-1.5 border-t border-default pt-4 text-xs/4.5 md:grid-cols-[auto_1fr]" + > + <dt class="text-muted">{{ t('Entry') }}</dt> + <dd :class="page.creating.value ? '' : 'font-mono'">{{ page.entryLine.value }}</dd> + <dt class="text-muted">{{ t('Created by') }}</dt> + <dd>{{ page.createdLine.value }}</dd> + <dt class="text-muted">{{ t('Updated by') }}</dt> + <dd>{{ page.updatedLine.value }}</dd> + </dl> + </form> +</template> diff --git a/web/src/components/EntryNotices.vue b/web/src/components/EntryNotices.vue new file mode 100644 index 0000000..307e69e --- /dev/null +++ b/web/src/components/EntryNotices.vue @@ -0,0 +1,70 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +import { computed } from 'vue' +import RetryAlert from '@/components/RetryAlert.vue' +import type { EntryEditorPage } from '@/composables/useEntryEditorPage' +import { schemaChangedLine, uniqueAlert } from '@/lib/entryMessages' + +const props = defineProps<{ page: EntryEditorPage }>() + +const { failure, schemaChanged, schemaField, form, fields, typeName } = props.page + +const uniqueField = computed(() => + fields.value.find((field) => form.issues.value[field.key]?.rule === 'unique'), +) +const alertUi = { + root: 'items-start gap-2.5 px-3.5 py-3', + title: 'font-semibold', + description: 'mt-0.5 text-toned', +} +</script> + +<template> + <RetryAlert + v-if="failure === 'failed'" + :title="t('Couldn\'t save the entry')" + :description=" + t( + 'Something went wrong on our side. Your changes are still here. Try again in a moment.', + ) + " + @retry="page.submit" + /> + + <UAlert + v-if="uniqueField" + role="alert" + color="error" + variant="soft" + icon="i-lucide-circle-x" + :title="t('Not saved')" + :description="uniqueAlert(uniqueField)" + :ui="alertUi" + /> + + <UAlert + v-if="schemaChanged" + role="alert" + color="warning" + variant="soft" + orientation="vertical" + icon="i-lucide-triangle-alert" + :title="t('The fields of {0} changed', [typeName])" + :description="schemaChangedLine(schemaField)" + :ui="{ ...alertUi, actions: 'mt-1.5' }" + > + <template #actions> + <UButton + color="neutral" + variant="outline" + icon="i-lucide-rotate-cw" + class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8" + :ui="{ leadingIcon: 'size-3.5' }" + @click="page.reload" + > + {{ t('Reload form') }} + </UButton> + </template> + </UAlert> +</template> diff --git a/web/src/components/EntryTable.vue b/web/src/components/EntryTable.vue index 45dbe90..d5a2a81 100644 --- a/web/src/components/EntryTable.vue +++ b/web/src/components/EntryTable.vue @@ -7,6 +7,7 @@ import FieldCell from '@/components/FieldCell.vue' import type { BuiltinField, ContentEntry } from '@/lib/contentTypes' import { EMPTY_TEXT, formatEntryDate } from '@/lib/entryCells' import { ENTRY_SORT_FIELDS, sortParts } from '@/lib/entryListQuery' +import { fieldTypeSpec } from '@/lib/fieldTypes' import { entryPath } from '@/lib/routePaths' const ID_WIDTH = 'w-16.5' @@ -15,7 +16,6 @@ const FIELD_WIDTH = 'w-33' const CREATED_WIDTH = 'w-30.5' const UPDATED_WIDTH = 'w-40.5' const UPDATED_BY_WIDTH = 'w-37' -const NARROW_TYPES = ['boolean', 'number'] const FIELD_PREFIX = 'field:' const props = defineProps<{ @@ -36,7 +36,7 @@ function widthOf(field: BuiltinField, index: number) { if (index === 0) { return '' } - return NARROW_TYPES.includes(field.type) ? NARROW_WIDTH : FIELD_WIDTH + return fieldTypeSpec(field.type).narrowColumn ? NARROW_WIDTH : FIELD_WIDTH } function column(id: string, header: string, width: string, edge = 'px-1.5') { @@ -62,7 +62,8 @@ function fieldOf(columnId: string) { } function isRight(columnId: string) { - return fieldOf(columnId)?.type === 'number' + const field = fieldOf(columnId) + return field ? fieldTypeSpec(field.type).alignEnd : false } function sortIcon(columnId: string) { diff --git a/web/src/components/FieldRow.vue b/web/src/components/FieldRow.vue index 070a019..63f56ff 100644 --- a/web/src/components/FieldRow.vue +++ b/web/src/components/FieldRow.vue @@ -6,7 +6,7 @@ import BuilderBadge from '@/components/BuilderBadge.vue' import FieldActions from '@/components/FieldActions.vue' import type { BuilderField } from '@/lib/schemaBuilderModel' import type { RowMarks } from '@/composables/useSchemaBuilder' -import { fieldIcon } from '@/lib/schemaFields' +import { fieldTypeSpec } from '@/lib/fieldTypes' const props = defineProps<{ field: BuilderField @@ -79,7 +79,7 @@ const rowClass = computed(() => [ class="flex size-7 shrink-0 items-center justify-center text-highlighted" :class="selected ? 'bg-default' : 'bg-elevated'" > - <UIcon :name="fieldIcon(field.type)" class="size-4" /> + <UIcon :name="fieldTypeSpec(field.type).icon" class="size-4" /> </span> <span class="flex min-w-0 flex-1 flex-col"> <span class="truncate font-medium" data-testid="field-row-label">{{ name }}</span> diff --git a/web/src/components/FieldTypePicker.vue b/web/src/components/FieldTypePicker.vue index a5ce072..31386c8 100644 --- a/web/src/components/FieldTypePicker.vue +++ b/web/src/components/FieldTypePicker.vue @@ -2,8 +2,7 @@ import { t } from '@/i18n' import { computed } from 'vue' -import { FIELD_TYPES, type FieldType } from '@/lib/schemaBuilderModel' -import { fieldIcon, fieldTypeLabel } from '@/lib/schemaFields' +import { FIELD_TYPES, FIELD_TYPE_SPECS, fieldTypeSpec, type FieldType } from '@/lib/fieldTypes' const props = defineProps<{ modelValue: FieldType @@ -14,27 +13,10 @@ const props = defineProps<{ defineEmits<{ 'update:modelValue': [type: FieldType] }>() -function describe(type: FieldType) { - switch (type) { - case 'richtext': - return t('Long text with formatting, written in Markdown') - case 'number': - return t('Whole or decimal numbers') - case 'boolean': - return t('An on or off switch') - case 'date': - return t('A calendar day, without a time') - case 'select': - return t('One option from a list you define') - default: - return t('Short text, like a title or a name') - } -} - const items = computed(() => FIELD_TYPES.map((type) => ({ value: type, - label: fieldTypeLabel(type), + label: FIELD_TYPE_SPECS[type].label(), disabled: props.locked && type !== props.modelValue, })), ) @@ -73,12 +55,12 @@ const items = computed(() => class="flex items-start gap-2.5 text-left" :class="item.disabled ? 'opacity-40' : ''" > - <UIcon :name="fieldIcon(item.value)" class="mt-0.5 size-4 shrink-0" /> + <UIcon :name="fieldTypeSpec(item.value).icon" class="mt-0.5 size-4 shrink-0" /> <span class="flex min-w-0 flex-col"> <span class="font-medium">{{ item.label }}</span> <span class="text-xs/4 font-normal text-pretty text-muted group-has-data-[state=checked]:text-(--rc-on-sand)" - >{{ describe(item.value) }}</span + >{{ fieldTypeSpec(item.value).description() }}</span > </span> </span> diff --git a/web/src/components/MarkdownField.vue b/web/src/components/MarkdownField.vue new file mode 100644 index 0000000..b3ec790 --- /dev/null +++ b/web/src/components/MarkdownField.vue @@ -0,0 +1,132 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +import { computed, nextTick, ref } from 'vue' +import { renderMarkdown } from '@/lib/markdown' +import { MARKDOWN_ACTIONS, applyMarkdownAction, type MarkdownAction } from '@/lib/markdownEdit' + +type Mode = 'write' | 'preview' + +const props = defineProps<{ + modelValue: string + disabled?: boolean + invalid?: boolean + required?: boolean +}>() + +const emit = defineEmits<{ 'update:modelValue': [value: string] }>() + +const mode = ref<Mode>('write') +const root = ref<HTMLElement | null>(null) + +const tabs = computed(() => [ + { label: t('Write'), value: 'write' }, + { label: t('Preview'), value: 'preview' }, +]) +const actions = computed(() => ({ + bold: { label: t('Bold'), icon: 'i-lucide-bold' }, + italic: { label: t('Italic'), icon: 'i-lucide-italic' }, + heading: { label: t('Heading'), icon: 'i-lucide-heading' }, + link: { label: t('Link'), icon: 'i-lucide-link' }, + list: { label: t('List'), icon: 'i-lucide-list' }, +})) +const rendered = computed(() => renderMarkdown(props.modelValue)) +const empty = computed(() => props.modelValue.trim() === '') + +function textarea() { + return root.value?.querySelector<HTMLTextAreaElement>('textarea') ?? null +} + +async function run(action: MarkdownAction) { + const field = textarea() + if (!field) { + return + } + const edit = applyMarkdownAction( + action, + props.modelValue, + field.selectionStart, + field.selectionEnd, + ) + emit('update:modelValue', edit.value) + await nextTick() + field.focus() + field.setSelectionRange(edit.start, edit.end) +} + +async function onMode(value: string | number) { + mode.value = value === 'preview' ? 'preview' : 'write' + if (mode.value === 'write') { + await nextTick() + textarea()?.focus() + } +} +</script> + +<template> + <div + ref="root" + data-testid="markdown-field" + class="flex min-w-0 flex-col border bg-default" + :class="[ + invalid ? 'border-error shadow-[inset_0_0_0_1px_var(--ui-error)]' : 'border-accented', + disabled ? 'bg-elevated' : '', + ]" + > + <div class="flex items-center gap-0.5 overflow-hidden border-b border-default px-1.5"> + <UTabs + :model-value="mode" + :items="tabs" + :content="false" + variant="link" + color="neutral" + :ui="{ + root: 'mr-2 w-auto gap-0', + list: 'gap-3.5 px-1.5', + trigger: 'h-11 px-0 text-base md:h-8', + indicator: 'bg-primary', + }" + @update:model-value="onMode" + /> + <template v-if="mode === 'write'"> + <UButton + v-for="action in MARKDOWN_ACTIONS" + :key="action" + type="button" + color="neutral" + variant="ghost" + :icon="actions[action].icon" + :aria-label="actions[action].label" + :disabled="disabled" + class="size-10 justify-center text-toned md:size-7" + :ui="{ leadingIcon: 'size-4' }" + @click="run(action)" + /> + </template> + </div> + <UTextarea + v-show="mode === 'write'" + :model-value="modelValue" + :placeholder="t('Write in Markdown')" + :disabled="disabled" + :aria-invalid="invalid ? 'true' : undefined" + :aria-required="required ? 'true' : undefined" + variant="none" + :rows="4" + autoresize + class="w-full" + :ui="{ + base: 'min-h-30 px-2.5 py-2 font-mono text-lg/5.5 md:min-h-24 md:text-xs/4.75', + }" + @update:model-value="emit('update:modelValue', String($event))" + /> + <div + v-if="mode === 'preview'" + data-testid="markdown-preview" + class="flex min-h-30 flex-col gap-2 px-3 py-2.5 text-lg md:min-h-24 md:text-sm [&_a]:text-primary [&_a]:underline [&_h1]:font-semibold [&_h2]:font-semibold [&_h3]:font-semibold [&_ul]:list-[square] [&_ul]:ps-4.5" + > + <p v-if="empty" class="text-muted">{{ t('Nothing to preview yet.') }}</p> + <div v-else class="flex flex-col gap-2" v-html="rendered" /> + </div> + </div> +</template> diff --git a/web/src/components/SchemaFieldTable.vue b/web/src/components/SchemaFieldTable.vue index 85d97be..b23f25b 100644 --- a/web/src/components/SchemaFieldTable.vue +++ b/web/src/components/SchemaFieldTable.vue @@ -4,7 +4,8 @@ import { t } from '@/i18n' import { computed } from 'vue' import { useMediaQuery } from '@vueuse/core' import type { BuiltinField } from '@/composables/useContentTypes' -import { fieldIcon, fieldRules, fieldTypeLabel } from '@/lib/schemaFields' +import { fieldTypeSpec } from '@/lib/fieldTypes' +import { fieldRules } from '@/lib/schemaFields' import { SCHEMA_TABLE_QUERY } from '@/lib/schemaRows' const NO_RULES = '–' @@ -43,13 +44,13 @@ const rows = computed(() => props.fields.map((field) => ({ field, rules: fieldRu :style="{ gridTemplateColumns: COLUMNS }" > <span role="cell" class="flex"> - <UIcon :name="fieldIcon(field.type)" class="size-4 text-toned" /> + <UIcon :name="fieldTypeSpec(field.type).icon" class="size-4 text-toned" /> </span> <span role="cell" class="truncate font-medium">{{ field.label }}</span> <span role="cell" class="truncate font-mono text-xs text-muted">{{ field.key }}</span> - <span role="cell">{{ fieldTypeLabel(field.type) }}</span> + <span role="cell">{{ fieldTypeSpec(field.type).label() }}</span> <span role="cell" class="text-muted">{{ rules || NO_RULES }}</span> </div> <div @@ -59,7 +60,7 @@ const rows = computed(() => props.fields.map((field) => ({ field, rules: fieldRu class="flex min-h-16 items-center gap-3 border-t border-default px-4 py-3" > <span class="flex size-7 shrink-0 items-center justify-center bg-elevated"> - <UIcon :name="fieldIcon(field.type)" class="size-4" /> + <UIcon :name="fieldTypeSpec(field.type).icon" class="size-4" /> </span> <div class="flex min-w-0 flex-1 flex-col"> <span class="font-medium">{{ field.label }}</span> diff --git a/web/src/components/__tests__/EntryControl.spec.ts b/web/src/components/__tests__/EntryControl.spec.ts new file mode 100644 index 0000000..ea24c01 --- /dev/null +++ b/web/src/components/__tests__/EntryControl.spec.ts @@ -0,0 +1,261 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import uiPlugin from '@nuxt/ui/vue-plugin' +import { CalendarDate } from '@internationalized/date' +import UCalendar from '@nuxt/ui/components/Calendar.vue' +import USelect from '@nuxt/ui/components/Select.vue' +import UInput from '@nuxt/ui/components/Input.vue' +import UInputDate from '@nuxt/ui/components/InputDate.vue' +import UInputNumber from '@nuxt/ui/components/InputNumber.vue' +import EntryField from '../EntryField.vue' +import EntryControl from '../EntryControl.vue' +import EntryDateField from '../EntryDateField.vue' +import MarkdownField from '../MarkdownField.vue' +import BuilderStatusChip from '../BuilderStatusChip.vue' +import type { BuiltinField } from '@/lib/contentTypes' + +let mounted: VueWrapper | undefined + +afterEach(() => { + mounted?.unmount() + mounted = undefined + document.body.innerHTML = '' +}) + +function field(type: string, rest: Partial<BuiltinField> = {}): BuiltinField { + return { + key: 'f', + label: 'Field', + type, + required: false, + unique: false, + default: null, + min: null, + max: null, + options: null, + ...rest, + } +} + +function mountControl(type: string, props: { value?: unknown; invalid?: boolean } = {}) { + mounted = mount(EntryControl, { + props: { field: field(type), value: null, invalid: false, disabled: false, ...props }, + global: { plugins: [uiPlugin] }, + }) + return mounted +} + +describe('EntryControl number', () => { + it.each([ + [NaN, null], + [undefined, null], + [0, 0], + [18, 18], + ])('emits %s as %s', (typed, sent) => { + const wrapper = mountControl('number') + + wrapper.findComponent(UInputNumber).vm.$emit('update:modelValue', typed) + + expect(wrapper.emitted('update')).toEqual([[sent]]) + }) +}) + +describe('EntryControl select', () => { + function mountSelect(options: string[]) { + mounted = mount(EntryControl, { + props: { + field: field('select', { options }), + value: '', + invalid: false, + disabled: false, + }, + global: { plugins: [uiPlugin] }, + }) + return mounted.findComponent(USelect) + } + + it('keeps an option named like the old sentinel apart from None', () => { + const select = mountSelect(['__none__', 'Talk']) + const items = select.props('items') as { label: string; value: string }[] + const none = items.find((item) => item.label === 'None')! + + expect(none.value).not.toBe('__none__') + select.vm.$emit('update:modelValue', '__none__') + select.vm.$emit('update:modelValue', none.value) + select.vm.$emit('update:modelValue', 'Talk') + + expect(mounted!.emitted('update')).toEqual([['__none__'], [''], ['Talk']]) + }) +}) + +describe('EntryControl invalid state', () => { + it('colors the control as an error only when invalid', () => { + expect(mountControl('text', { invalid: true }).findComponent(UInput).props('color')).toBe( + 'error', + ) + mounted?.unmount() + expect( + mountControl('text', { invalid: false }).findComponent(UInput).props('color'), + ).toBeUndefined() + }) +}) + +describe('Markdown field accessibility', () => { + function mountField(required: boolean, issue?: { rule: 'required'; message: string }) { + mounted = mount(EntryField, { + props: { + field: field('richtext', { key: 'notes', label: 'Notes', required }), + value: '', + issue, + isDefault: false, + disabled: false, + }, + global: { plugins: [uiPlugin] }, + }) + return mounted.find('textarea') + } + + it('ties the textarea to its label and its message and marks it required', () => { + const area = mountField(true, { rule: 'required', message: 'Fill in Notes.' }) + + expect(mounted!.find('label').attributes('for')).toBe(area.attributes('id')) + expect(area.attributes('aria-describedby')).toBe(`${area.attributes('id')}-error`) + expect(area.attributes('aria-invalid')).toBe('true') + expect(area.attributes('aria-required')).toBe('true') + }) + + it('does not mark an optional textarea as required', () => { + expect(mountField(false).attributes('aria-required')).toBeUndefined() + }) +}) + +describe('EntryDateField', () => { + function mountDate(modelValue: string) { + mounted = mount(EntryDateField, { + props: { modelValue }, + attachTo: document.body, + global: { plugins: [uiPlugin] }, + }) + return mounted + } + + it('emits an ISO day for a picked date and an empty string when cleared', () => { + const wrapper = mountDate('2026-11-21') + const input = wrapper.findComponent(UInputDate) + + input.vm.$emit('update:modelValue', new CalendarDate(2026, 12, 3)) + input.vm.$emit('update:modelValue', null) + + expect(wrapper.emitted('update:modelValue')).toEqual([['2026-12-03'], ['']]) + }) + + it('shows the placeholder only while empty and not focused', async () => { + const wrapper = mountDate('') + const input = wrapper.findComponent(UInputDate) + expect(wrapper.text()).toContain('Pick a date') + + input.vm.$emit('focus') + await flushPromises() + expect(wrapper.text()).not.toContain('Pick a date') + + input.vm.$emit('blur') + await flushPromises() + expect(wrapper.text()).toContain('Pick a date') + }) + + it('closes the calendar once a day is picked', async () => { + const wrapper = mountDate('2026-11-21') + await wrapper.find('button[aria-label="Open calendar"]').trigger('click') + await flushPromises() + const calendar = wrapper.findComponent(UCalendar) + expect(calendar.exists()).toBe(true) + + calendar.vm.$emit('update:modelValue', new CalendarDate(2026, 11, 25)) + await flushPromises() + + expect(wrapper.emitted('update:modelValue')).toEqual([['2026-11-25']]) + expect(wrapper.findComponent(UCalendar).exists()).toBe(false) + }) + + it('hides the placeholder when a date is set', () => { + expect(mountDate('2026-11-21').text()).not.toContain('Pick a date') + }) +}) + +describe('MarkdownField', () => { + function mountMarkdown(modelValue: string) { + mounted = mount(MarkdownField, { + props: { modelValue }, + attachTo: document.body, + global: { plugins: [uiPlugin] }, + }) + return mounted + } + + async function openPreview(wrapper: VueWrapper) { + const tabs = wrapper.findAll('[role="tab"]') + const preview = tabs.find((candidate) => candidate.text() === 'Preview')! + await preview.trigger('mousedown', { button: 0 }) + await preview.trigger('click') + await flushPromises() + } + + it('treats whitespace as nothing to preview', async () => { + const wrapper = mountMarkdown(' \n ') + + await openPreview(wrapper) + + expect(wrapper.text()).toContain('Nothing to preview yet.') + }) + + it('selects the result of a toolbar action and keeps the focus in the textarea', async () => { + const wrapper = mountMarkdown('lime') + const area = wrapper.find('textarea').element + area.setSelectionRange(0, 4) + + await wrapper.find('button[aria-label="Link"]').trigger('click') + await flushPromises() + + expect(wrapper.emitted('update:modelValue')).toEqual([['[lime](url)']]) + expect(document.activeElement).toBe(area) + await wrapper.setProps({ modelValue: '[lime](url)' }) + area.setSelectionRange(0, 0) + await wrapper.find('button[aria-label="Bold"]').trigger('click') + await flushPromises() + expect([area.selectionStart, area.selectionEnd]).toEqual([2, 2]) + }) + + it('puts the focus back in the textarea when returning to Write', async () => { + const wrapper = mountMarkdown('lime') + await openPreview(wrapper) + ;(document.activeElement as HTMLElement | null)?.blur() + + const write = wrapper + .findAll('[role="tab"]') + .find((candidate) => candidate.text() === 'Write')! + await write.trigger('mousedown', { button: 0 }) + await write.trigger('click') + await flushPromises() + + expect(document.activeElement).toBe(wrapper.find('textarea').element) + }) +}) + +describe('BuilderStatusChip colors', () => { + function chipClasses(status: 'dirty' | 'unsaved' | 'saved') { + mounted = mount(BuilderStatusChip, { + props: { status }, + global: { plugins: [uiPlugin] }, + }) + return mounted.find('[data-testid="builder-chip"]').classes() + } + + it.each([ + ['dirty', 'text-warning'], + ['unsaved', 'text-error'], + ['saved', 'text-muted'], + ] as const)('paints %s as %s', (status, color) => { + expect(chipClasses(status)).toContain(color) + mounted?.unmount() + }) +}) diff --git a/web/src/composables/__tests__/useContentTypeDetail.spec.ts b/web/src/composables/__tests__/useContentTypeDetail.spec.ts new file mode 100644 index 0000000..a52e248 --- /dev/null +++ b/web/src/composables/__tests__/useContentTypeDetail.spec.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises } from '@vue/test-utils' +import { ref } from 'vue' +import { useContentTypeDetail } from '@/composables/useContentTypeDetail' + +const getContentType = vi.fn() + +vi.mock('@/composables/useContentTypes', () => ({ useContentTypes: () => ({ getContentType }) })) + +beforeEach(() => { + getContentType.mockReset() + getContentType.mockResolvedValue({ key: 'events', fields: [] }) +}) + +describe('useContentTypeDetail', () => { + it('requests the type at once by default', async () => { + const detail = useContentTypeDetail(() => 'events') + await flushPromises() + + expect(getContentType).toHaveBeenCalledWith('events') + expect(detail.status.value).toBe('ready') + }) + + it('waits to be enabled before it requests anything', async () => { + const enabled = ref(false) + + const detail = useContentTypeDetail( + () => 'events', + () => enabled.value, + ) + await flushPromises() + + expect(getContentType).not.toHaveBeenCalled() + expect(detail.status.value).toBe('loading') + + enabled.value = true + await flushPromises() + + expect(getContentType).toHaveBeenCalledTimes(1) + expect(detail.status.value).toBe('ready') + }) +}) diff --git a/web/src/composables/__tests__/useContentTypes.spec.ts b/web/src/composables/__tests__/useContentTypes.spec.ts index 31752df..9cf7caa 100644 --- a/web/src/composables/__tests__/useContentTypes.spec.ts +++ b/web/src/composables/__tests__/useContentTypes.spec.ts @@ -303,97 +303,3 @@ describe('getContentType', () => { ) }) }) - -describe('listEntries', () => { - const page = { items: [{ id: 1 }], total: 37, page: 2, size: 5, sort: 'id:asc' } - - it('builds the query with explicit page, size and sort and returns the page', async () => { - answer(200, page) - - const listed = await useContentTypes().listEntries('events', { - page: 2, - size: 5, - sort: 'id:asc', - }) - - expect(listed).toEqual(page) - expect(fetchMock.mock.calls[0]![0]).toBe( - '/v1/content-type/events/entry?page=2&size=5&sort=id:asc', - ) - }) - - it.each([ - [403, 'forbidden'], - [404, 'content_type_not_found'], - [500, 'boom'], - ])('passes %i and its plain text body through', async (status, body) => { - answer(status, body) - - await expect( - useContentTypes().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }), - ).rejects.toMatchObject({ status, body }) - }) - - it('lets a network failure through', async () => { - fetchMock.mockRejectedValue(new TypeError('Failed to fetch')) - - await expect( - useContentTypes().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }), - ).rejects.toBeInstanceOf(NetworkError) - }) - - it.each([[{ total: 1 }], [{ items: [] }], [{ items: [], total: '1' }], [null]])( - 'rejects a malformed page: %j', - async (body) => { - answer(200, body) - - await expect( - useContentTypes().listEntries('events', { - page: 1, - size: 25, - sort: 'updated_at:desc', - }), - ).rejects.toThrow() - }, - ) - - it('sends a 401 to the login page with the expired reason', async () => { - answer(401, '') - - await expect( - useContentTypes().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }), - ).rejects.toMatchObject({ status: 401 }) - - const { default: router } = await import('@/router') - await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login')) - expect(router.currentRoute.value.query.reason).toBe('expired') - await router.push('/login') - }) -}) - -describe('lookupUsers', () => { - it('sends the distinct ids once and returns the display names', async () => { - answer(200, [ - { id: 3, display_name: 'Marta Alves' }, - { id: 5, display_name: 'Jonas Pereira' }, - ]) - - const names = await useContentTypes().lookupUsers([3, 3, 5, 3]) - - expect(fetchMock).toHaveBeenCalledTimes(1) - expect(fetchMock.mock.calls[0]![0]).toBe('/v1/user/lookup?ids=3,5') - expect(names.map((user) => user.display_name)).toEqual(['Marta Alves', 'Jonas Pereira']) - }) - - it('rejects with the status on a 500', async () => { - answer(500, 'boom') - - await expect(useContentTypes().lookupUsers([3])).rejects.toMatchObject({ status: 500 }) - }) - - it('rejects a body that is not a list', async () => { - answer(200, { id: 3 }) - - await expect(useContentTypes().lookupUsers([3])).rejects.toThrow() - }) -}) diff --git a/web/src/composables/__tests__/useEntries.spec.ts b/web/src/composables/__tests__/useEntries.spec.ts new file mode 100644 index 0000000..7b61afd --- /dev/null +++ b/web/src/composables/__tests__/useEntries.spec.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { NetworkError } from '@/composables/useApi' +import { useEntries } from '@/composables/useEntries' + +const fetchMock = vi.fn() + +function answer(status: number, body: unknown) { + fetchMock.mockResolvedValue( + new Response(typeof body === 'string' ? body : JSON.stringify(body), { + status, + }), + ) +} + +beforeEach(() => { + setActivePinia(createPinia()) + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('listEntries', () => { + const page = { items: [{ id: 1 }], total: 37, page: 2, size: 5 } + + it('builds the query with explicit page, size and sort and returns the page', async () => { + answer(200, page) + + const listed = await useEntries().listEntries('events', { + page: 2, + size: 5, + sort: 'id:asc', + }) + + expect(listed).toEqual(page) + expect(fetchMock.mock.calls[0]![0]).toBe( + '/v1/content-type/events/entry?page=2&size=5&sort=id:asc', + ) + }) + + it.each([ + [403, 'forbidden'], + [404, 'content_type_not_found'], + [500, 'boom'], + ])('passes %i and its plain text body through', async (status, body) => { + answer(status, body) + + await expect( + useEntries().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }), + ).rejects.toMatchObject({ status, body }) + }) + + it('lets a network failure through', async () => { + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')) + + await expect( + useEntries().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }), + ).rejects.toBeInstanceOf(NetworkError) + }) + + it.each([[{ total: 1 }], [{ items: [] }], [{ items: [], total: '1' }], [null]])( + 'rejects a malformed page: %j', + async (body) => { + answer(200, body) + + await expect( + useEntries().listEntries('events', { + page: 1, + size: 25, + sort: 'updated_at:desc', + }), + ).rejects.toThrow() + }, + ) + + it('sends a 401 to the login page with the expired reason', async () => { + answer(401, '') + + await expect( + useEntries().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }), + ).rejects.toMatchObject({ status: 401 }) + + const { default: router } = await import('@/router') + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login')) + expect(router.currentRoute.value.query.reason).toBe('expired') + await router.push('/login') + }) +}) + +describe('entry writes', () => { + const entry = { + id: 43, + content_type: 'events', + data: { title: 'Open day' }, + created_by: 1, + updated_by: 1, + created_at: '2026-10-19T11:02:00Z', + updated_at: '2026-10-19T11:02:00Z', + } + + it('gets an entry by id', async () => { + answer(200, entry) + + await expect(useEntries().getEntry('events', 43)).resolves.toEqual(entry) + expect(fetchMock.mock.calls[0]![0]).toBe('/v1/content-type/events/entry/43') + }) + + it('creates with the data wrapped in {data}', async () => { + answer(201, entry) + + await useEntries().createEntry('events', { title: 'Open day', ends_on: null }) + + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('/v1/content-type/events/entry') + expect(init.method).toBe('POST') + expect(JSON.parse(init.body)).toEqual({ data: { title: 'Open day', ends_on: null } }) + }) + + it('replaces an entry with PUT', async () => { + answer(200, entry) + + await useEntries().saveEntry('events', 43, { title: 'Open day' }) + + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('/v1/content-type/events/entry/43') + expect(init.method).toBe('PUT') + expect(JSON.parse(init.body)).toEqual({ data: { title: 'Open day' } }) + }) + + it('deletes with DELETE and resolves on 204', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })) + + await expect(useEntries().deleteEntry('events', 43)).resolves.toBeUndefined() + expect(fetchMock.mock.calls[0]![1].method).toBe('DELETE') + }) + + it.each([ + [422, 'invalid_entry: title: required'], + [409, 'invalid_entry: title: unique'], + [404, 'entry_not_found'], + [404, 'content_type_not_found'], + [400, 'invalid id'], + [403, 'forbidden'], + ])('passes %i "%s" through with its body', async (status, body) => { + answer(status, body) + + await expect(useEntries().saveEntry('events', 43, {})).rejects.toMatchObject({ + status, + body, + }) + }) + + it('lets a network failure through', async () => { + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')) + + await expect(useEntries().createEntry('events', {})).rejects.toBeInstanceOf(NetworkError) + }) + + it.each([ + ['data is a string', { id: 1, data: 'x' }], + ['data is null', { id: 1, data: null }], + ['id is missing', { data: {} }], + ])('rejects an entry whose %s', async (_name, body) => { + answer(200, body) + + await expect(useEntries().getEntry('events', 1)).rejects.toThrow('Unexpected response') + }) + + it('rejects an answer that is not an entry', async () => { + answer(200, { items: [] }) + + await expect(useEntries().getEntry('events', 43)).rejects.toThrow('Unexpected response') + }) +}) diff --git a/web/src/composables/__tests__/useEntryLoad.spec.ts b/web/src/composables/__tests__/useEntryLoad.spec.ts new file mode 100644 index 0000000..420c35b --- /dev/null +++ b/web/src/composables/__tests__/useEntryLoad.spec.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiError } from '@/composables/useApi' +import { useEntryLoad } from '@/composables/useEntryLoad' +import type { ContentEntry } from '@/lib/contentTypes' + +const getEntry = vi.fn() + +vi.mock('@/composables/useEntries', () => ({ useEntries: () => ({ getEntry }) })) + +function entry(id: number, title: string): ContentEntry { + return { + id, + content_type: 'events', + data: { title }, + created_by: 1, + updated_by: 1, + created_at: '2026-10-12T09:14:00', + updated_at: '2026-10-12T09:14:00', + } +} + +function gate() { + let resolve!: (value: ContentEntry) => void + const promise = new Promise<ContentEntry>((done) => (resolve = done)) + return { promise, resolve } +} + +beforeEach(() => { + getEntry.mockReset() +}) + +describe('initial status', () => { + it.each([ + ['new', 'idle'], + ['invalid', 'missing'], + [42, 'loading'], + ] as const)('starts a %s target as %s', (target, status) => { + expect(useEntryLoad('events', target).status.value).toBe(status) + }) + + it('requests nothing for a new or invalid target', async () => { + await useEntryLoad('events', 'new').start() + await useEntryLoad('events', 'invalid').start() + + expect(getEntry).not.toHaveBeenCalled() + }) +}) + +describe('stale answers', () => { + it('keeps the answer of the latest request', async () => { + const first = gate() + const second = gate() + getEntry.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const load = useEntryLoad('events', 1) + + const started = load.start() + const retried = load.retry(1) + second.resolve(entry(1, 'second')) + await retried + first.resolve(entry(1, 'first')) + await started + + expect(load.entry.value?.data.title).toBe('second') + expect(load.status.value).toBe('ready') + }) + + it('drops a stale failure', async () => { + let fail!: (error: unknown) => void + const first = new Promise<ContentEntry>((_resolve, reject) => (fail = reject)) + const second = gate() + getEntry.mockReturnValueOnce(first).mockReturnValueOnce(second.promise) + const load = useEntryLoad('events', 1) + + const started = load.start() + const retried = load.retry(1) + second.resolve(entry(1, 'second')) + await retried + fail(new ApiError(500)) + await started + + expect(load.status.value).toBe('ready') + expect(load.entry.value?.data.title).toBe('second') + }) + + it('ignores an in-flight load once an entry is adopted', async () => { + const pendingLoad = gate() + getEntry.mockReturnValueOnce(pendingLoad.promise) + const load = useEntryLoad('events', 1) + + const started = load.start() + load.adopt(entry(1, 'saved')) + pendingLoad.resolve(entry(1, 'loaded')) + await started + + expect(load.entry.value?.data.title).toBe('saved') + }) + + it('ignores an in-flight load once the entry is marked gone', async () => { + const pendingLoad = gate() + getEntry.mockReturnValueOnce(pendingLoad.promise) + const load = useEntryLoad('events', 1) + + const started = load.start() + load.markGone(new ApiError(404, undefined, 'entry_not_found')) + pendingLoad.resolve(entry(1, 'loaded')) + await started + + expect(load.status.value).toBe('missing') + expect(load.entry.value).toBeNull() + }) +}) + +describe('failures', () => { + it.each([ + [403, undefined, 'forbidden'], + [404, 'entry_not_found', 'missing'], + [404, 'content_type_not_found', 'typeMissing'], + [500, undefined, 'failed'], + ])('maps %i %s to %s', async (status, body, expected) => { + getEntry.mockRejectedValueOnce(new ApiError(status, undefined, body)) + const load = useEntryLoad('events', 1) + + await load.start() + + expect(load.status.value).toBe(expected) + }) + + it('leaves the status alone on 401', async () => { + getEntry.mockRejectedValueOnce(new ApiError(401)) + const load = useEntryLoad('events', 1) + + await load.start() + + expect(load.status.value).toBe('loading') + }) +}) diff --git a/web/src/composables/__tests__/useEntryNames.spec.ts b/web/src/composables/__tests__/useEntryNames.spec.ts new file mode 100644 index 0000000..370ce72 --- /dev/null +++ b/web/src/composables/__tests__/useEntryNames.spec.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises } from '@vue/test-utils' +import { ref } from 'vue' +import { useEntryNames } from '@/composables/useEntryNames' +import type { ContentEntry } from '@/lib/contentTypes' + +const lookupUsers = vi.fn() + +vi.mock('@/composables/useUserLookup', () => ({ useUserLookup: () => ({ lookupUsers }) })) + +function entry(createdBy: number, updatedBy: number): ContentEntry { + return { + id: 1, + content_type: 'events', + data: {}, + created_by: createdBy, + updated_by: updatedBy, + created_at: '2026-10-12T09:14:00', + updated_at: '2026-10-12T09:14:00', + } +} + +beforeEach(() => { + lookupUsers.mockReset() +}) + +describe('useEntryNames', () => { + it('maps the ids of the entry to display names', async () => { + lookupUsers.mockResolvedValue([ + { id: 4, display_name: 'Inès Duarte' }, + { id: 3, display_name: 'Marta Alves' }, + ]) + const current = ref<ContentEntry | null>(entry(4, 3)) + + const names = useEntryNames(() => current.value) + await flushPromises() + + expect(lookupUsers).toHaveBeenCalledWith([4, 3]) + expect(names.value).toEqual({ 4: 'Inès Duarte', 3: 'Marta Alves' }) + }) + + it('does not look up the unknown author id 0', async () => { + lookupUsers.mockResolvedValue([{ id: 3, display_name: 'Marta Alves' }]) + const current = ref<ContentEntry | null>(entry(0, 3)) + + useEntryNames(() => current.value) + await flushPromises() + + expect(lookupUsers).toHaveBeenCalledWith([3]) + }) + + it('looks up nobody when both ids are 0 or the entry is missing', async () => { + const current = ref<ContentEntry | null>(entry(0, 0)) + + const names = useEntryNames(() => current.value) + current.value = null + await flushPromises() + + expect(lookupUsers).not.toHaveBeenCalled() + expect(names.value).toEqual({}) + }) + + it('keeps the names it knows and looks up only a new id', async () => { + lookupUsers + .mockResolvedValueOnce([ + { id: 4, display_name: 'Inès Duarte' }, + { id: 3, display_name: 'Marta Alves' }, + ]) + .mockResolvedValueOnce([{ id: 5, display_name: 'Jonas Pereira' }]) + const current = ref<ContentEntry | null>(entry(4, 3)) + + const names = useEntryNames(() => current.value) + await flushPromises() + current.value = entry(4, 5) + await flushPromises() + + expect(lookupUsers).toHaveBeenCalledTimes(2) + expect(lookupUsers).toHaveBeenLastCalledWith([5]) + expect(names.value).toEqual({ 4: 'Inès Duarte', 3: 'Marta Alves', 5: 'Jonas Pereira' }) + }) + + it('does not look up again nor clear the names when the same ids come back', async () => { + lookupUsers.mockResolvedValue([ + { id: 4, display_name: 'Inès Duarte' }, + { id: 3, display_name: 'Marta Alves' }, + ]) + const current = ref<ContentEntry | null>(entry(4, 3)) + + const names = useEntryNames(() => current.value) + await flushPromises() + current.value = { ...entry(4, 3), updated_at: '2026-10-19T11:02:00' } + await flushPromises() + + expect(lookupUsers).toHaveBeenCalledTimes(1) + expect(names.value).toEqual({ 4: 'Inès Duarte', 3: 'Marta Alves' }) + }) + + it('asks again for an id whose lookup failed once a new id shows up', async () => { + lookupUsers.mockRejectedValueOnce(new Error('down')).mockResolvedValueOnce([ + { id: 4, display_name: 'Inès Duarte' }, + { id: 5, display_name: 'Jonas Pereira' }, + ]) + const current = ref<ContentEntry | null>(entry(4, 4)) + + const names = useEntryNames(() => current.value) + await flushPromises() + current.value = entry(4, 5) + await flushPromises() + + expect(lookupUsers).toHaveBeenLastCalledWith([4, 5]) + expect(names.value).toEqual({ 4: 'Inès Duarte', 5: 'Jonas Pereira' }) + }) + + it('reads no names when the lookup fails', async () => { + lookupUsers.mockRejectedValue(new Error('down')) + + const names = useEntryNames(() => entry(4, 3)) + await flushPromises() + + expect(names.value).toEqual({}) + }) +}) diff --git a/web/src/composables/__tests__/useEntryRemoval.spec.ts b/web/src/composables/__tests__/useEntryRemoval.spec.ts new file mode 100644 index 0000000..f52156d --- /dev/null +++ b/web/src/composables/__tests__/useEntryRemoval.spec.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { ApiError } from '@/composables/useApi' +import { useEntryRemoval } from '@/composables/useEntryRemoval' +import type { EntryDraft } from '@/composables/useEntryDraft' + +const deleteEntry = vi.fn() +const replace = vi.fn() +const add = vi.fn() + +vi.mock('@/composables/useEntries', () => ({ useEntries: () => ({ deleteEntry }) })) +vi.mock('vue-router', async (importOriginal) => ({ + ...(await importOriginal<typeof import('vue-router')>()), + useRouter: () => ({ replace }), +})) +vi.mock('@/i18n/presentation', () => ({ useLocalizedToast: () => ({ add }) })) + +function fakeEditor(overrides: { id?: number | null; saving?: boolean } = {}) { + const markGone = vi.fn() + const editor = { + typeKey: 'events', + entryId: ref(overrides.id === undefined ? 42 : overrides.id), + saving: ref(overrides.saving ?? false), + load: { markGone }, + } as unknown as EntryDraft + return { editor, markGone } +} + +function gate() { + let resolve!: () => void + const promise = new Promise<void>((done) => (resolve = done)) + return { promise, resolve } +} + +beforeEach(() => { + deleteEntry.mockReset() + replace.mockReset() + add.mockReset() + replace.mockResolvedValue(undefined) +}) + +describe('open and close', () => { + it('opens the confirmation for a saved entry', () => { + const removal = useEntryRemoval( + fakeEditor().editor, + () => 'Events', + () => 'Lime', + ) + + removal.open() + + expect(removal.stage.value).toBe('confirm') + }) + + it.each([ + ['an unsaved entry', { id: null }], + ['a form that is saving', { saving: true }], + ])('does not open for %s', (_name, overrides) => { + const removal = useEntryRemoval( + fakeEditor(overrides).editor, + () => 'Events', + () => 'Lime', + ) + + removal.open() + + expect(removal.stage.value).toBe('closed') + }) + + it('closes the confirmation when nothing is pending', () => { + const removal = useEntryRemoval( + fakeEditor().editor, + () => 'Events', + () => 'Lime', + ) + removal.open() + + removal.close() + + expect(removal.stage.value).toBe('closed') + }) + + it('refuses to close while the deletion is pending', async () => { + const request = gate() + deleteEntry.mockReturnValue(request.promise) + const removal = useEntryRemoval( + fakeEditor().editor, + () => 'Events', + () => 'Lime', + ) + removal.open() + + const confirming = removal.confirm() + removal.close() + + expect(removal.stage.value).toBe('confirm') + request.resolve() + await confirming + }) +}) + +describe('confirm', () => { + it('sends a single request when confirmed twice', async () => { + const request = gate() + deleteEntry.mockReturnValue(request.promise) + const removal = useEntryRemoval( + fakeEditor().editor, + () => 'Events', + () => 'Lime', + ) + removal.open() + + const first = removal.confirm() + const second = removal.confirm() + request.resolve() + await Promise.all([first, second]) + + expect(deleteEntry).toHaveBeenCalledTimes(1) + expect(deleteEntry).toHaveBeenCalledWith('events', 42) + }) + + it('announces the title it had before the deletion and leaves for the list', async () => { + deleteEntry.mockResolvedValue(undefined) + let title = 'Lime' + const removal = useEntryRemoval( + fakeEditor().editor, + () => 'Events', + () => title, + ) + removal.open() + + const confirming = removal.confirm() + title = 'changed meanwhile' + await confirming + + expect(add).toHaveBeenCalledTimes(1) + const toast = add.mock.calls[0]![0]() + expect(toast.description).toBe('"Lime" was deleted from Events.') + expect(replace).toHaveBeenCalledWith('/content/events') + expect(removal.done.value).toBe(true) + expect(removal.stage.value).toBe('closed') + }) + + it('re-arms the unsaved changes prompt when the navigation is blocked', async () => { + deleteEntry.mockResolvedValue(undefined) + replace.mockResolvedValue(new Error('cancelled')) + const removal = useEntryRemoval( + fakeEditor().editor, + () => 'Events', + () => 'Lime', + ) + removal.open() + + await removal.confirm() + + expect(removal.done.value).toBe(false) + }) + + it('re-arms the unsaved changes prompt when the navigation throws', async () => { + deleteEntry.mockResolvedValue(undefined) + replace.mockRejectedValue(new Error('boom')) + const removal = useEntryRemoval( + fakeEditor().editor, + () => 'Events', + () => 'Lime', + ) + removal.open() + + await removal.confirm() + + expect(removal.done.value).toBe(false) + }) + + it.each([404, 403])('marks the entry gone on %i and closes the dialog', async (status) => { + const error = new ApiError(status) + deleteEntry.mockRejectedValue(error) + const { editor, markGone } = fakeEditor() + const removal = useEntryRemoval( + editor, + () => 'Events', + () => 'Lime', + ) + removal.open() + + await removal.confirm() + + expect(markGone).toHaveBeenCalledWith(error) + expect(removal.stage.value).toBe('closed') + expect(removal.pending.value).toBe(false) + }) + + it('keeps the dialog open with a failure on 500', async () => { + deleteEntry.mockRejectedValue(new ApiError(500)) + const { editor, markGone } = fakeEditor() + const removal = useEntryRemoval( + editor, + () => 'Events', + () => 'Lime', + ) + removal.open() + + await removal.confirm() + + expect(removal.stage.value).toBe('failed') + expect(markGone).not.toHaveBeenCalled() + expect(removal.done.value).toBe(false) + }) + + it('stays silent on 401', async () => { + deleteEntry.mockRejectedValue(new ApiError(401)) + const removal = useEntryRemoval( + fakeEditor().editor, + () => 'Events', + () => 'Lime', + ) + removal.open() + + await removal.confirm() + + expect(removal.stage.value).toBe('confirm') + }) + + it('does nothing for an unsaved entry', async () => { + const removal = useEntryRemoval( + fakeEditor({ id: null }).editor, + () => 'Events', + () => 'Lime', + ) + + await removal.confirm() + + expect(deleteEntry).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/composables/__tests__/useUserLookup.spec.ts b/web/src/composables/__tests__/useUserLookup.spec.ts new file mode 100644 index 0000000..435ab4f --- /dev/null +++ b/web/src/composables/__tests__/useUserLookup.spec.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useUserLookup } from '@/composables/useUserLookup' + +const fetchMock = vi.fn() + +function answer(status: number, body: unknown) { + fetchMock.mockResolvedValue( + new Response(typeof body === 'string' ? body : JSON.stringify(body), { + status, + }), + ) +} + +beforeEach(() => { + setActivePinia(createPinia()) + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('lookupUsers', () => { + it('sends the distinct ids once and returns the display names', async () => { + answer(200, [ + { id: 3, display_name: 'Marta Alves' }, + { id: 5, display_name: 'Jonas Pereira' }, + ]) + + const names = await useUserLookup().lookupUsers([3, 3, 5, 3]) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0]![0]).toBe('/v1/user/lookup?ids=3,5') + expect(names.map((user) => user.display_name)).toEqual(['Marta Alves', 'Jonas Pereira']) + }) + + it('rejects with the status on a 500', async () => { + answer(500, 'boom') + + await expect(useUserLookup().lookupUsers([3])).rejects.toMatchObject({ status: 500 }) + }) + + it('rejects a body that is not a list', async () => { + answer(200, { id: 3 }) + + await expect(useUserLookup().lookupUsers([3])).rejects.toThrow() + }) +}) diff --git a/web/src/composables/useApi.ts b/web/src/composables/useApi.ts index 439d40d..c4d1236 100644 --- a/web/src/composables/useApi.ts +++ b/web/src/composables/useApi.ts @@ -1,19 +1,9 @@ import { ref } from 'vue' import router from '@/router' import { useSessionStore } from '@/stores/session' +import { ApiError } from '@/lib/apiError' -export class ApiError extends Error { - status: number - retryAfter?: number - body?: string - - constructor(status: number, retryAfter?: number, body?: string) { - super(`Request failed with status ${status}`) - this.status = status - this.retryAfter = retryAfter - this.body = body - } -} +export { ApiError } export class NetworkError extends Error { constructor() { diff --git a/web/src/composables/useContentListPage.ts b/web/src/composables/useContentListPage.ts new file mode 100644 index 0000000..2b352f3 --- /dev/null +++ b/web/src/composables/useContentListPage.ts @@ -0,0 +1,98 @@ +import { computed } from 'vue' +import { useRouter } from 'vue-router' +import { useMediaQuery } from '@vueuse/core' +import { useContentTypeDetail } from '@/composables/useContentTypeDetail' +import { useEntryColumns } from '@/composables/useEntryColumns' +import { useEntryList } from '@/composables/useEntryList' +import { resolveContentListState } from '@/lib/contentListState' +import { entryCaption } from '@/lib/entryListQuery' +import { fieldCountLabel } from '@/lib/schemaRows' +import { HOME_PATH, entryPath, schemaPath } from '@/lib/routePaths' + +const DESKTOP_QUERY = '(min-width: 48rem)' +const DESKTOP_SKELETON_ROWS = 8 +const MOBILE_SKELETON_ROWS = 6 + +export function useContentListPage( + typeKey: string, + detail: ReturnType<typeof useContentTypeDetail>, + canEdit: () => boolean, +) { + const router = useRouter() + const isDesktop = useMediaQuery(DESKTOP_QUERY) + const list = useEntryList(typeKey) + const columns = useEntryColumns(typeKey, () => detail.type.value?.fields ?? []) + + const view = computed(() => + resolveContentListState({ + type: detail.status.value, + entries: list.status.value, + total: list.total.value, + }), + ) + const centered = computed(() => view.value === 'forbidden' || view.value === 'notFound') + const failed = computed(() => view.value === 'typeError' || view.value === 'entriesError') + const name = computed(() => detail.type.value?.name ?? typeKey) + const fields = computed(() => detail.type.value?.fields ?? []) + const subtitle = computed(() => + detail.type.value + ? `${detail.type.value.key} · ${fieldCountLabel(detail.type.value.fields.length)}` + : '', + ) + const editTo = computed(() => (canEdit() ? schemaPath(typeKey) : undefined)) + const caption = computed(() => entryCaption(name.value, list.shown.value.sort)) + + const showSkeleton = computed(() => + view.value === 'loading' + ? list.showSkeleton.value || list.status.value === 'ready' + : view.value === 'list' && list.skeletonActive.value, + ) + const skeletonRows = computed(() => { + if (view.value === 'list') { + return list.items.value.length + } + return isDesktop.value ? DESKTOP_SKELETON_ROWS : MOBILE_SKELETON_ROWS + }) + const showBody = computed(() => view.value === 'list' && !showSkeleton.value) + + function createEntry() { + router.push(entryPath(typeKey, 'new')) + } + + function goHome() { + router.push(HOME_PATH) + } + + function retry() { + if (view.value === 'typeError') { + detail.retry() + return + } + list.retry() + } + + return { + typeKey, + type: detail.type, + typeStatus: detail.status, + isDesktop, + list, + columns, + view, + centered, + failed, + name, + fields, + subtitle, + editTo, + caption, + showSkeleton, + skeletonRows, + showBody, + createEntry, + goHome, + retry, + } +} + +export type ContentListPage = ReturnType<typeof useContentListPage> diff --git a/web/src/composables/useContentTypeDetail.ts b/web/src/composables/useContentTypeDetail.ts index 718f22a..959ec7b 100644 --- a/web/src/composables/useContentTypeDetail.ts +++ b/web/src/composables/useContentTypeDetail.ts @@ -5,7 +5,7 @@ import type { ContentType, ContentTypeDetailStatus } from '@/lib/contentTypes' const FAILURES: Record<number, ContentTypeDetailStatus> = { 403: 'forbidden', 404: 'missing' } -export function useContentTypeDetail(key: () => string) { +export function useContentTypeDetail(key: () => string, enabled: () => boolean = () => true) { const { getContentType } = useContentTypes() const status = ref<ContentTypeDetailStatus>('loading') const type = ref<ContentType | null>(null) @@ -29,7 +29,15 @@ export function useContentTypeDetail(key: () => string) { } } - watch(key, load, { immediate: true }) + watch( + [key, enabled], + ([, isEnabled]) => { + if (isEnabled) { + return load() + } + }, + { immediate: true }, + ) return { status, type, retry: load } } diff --git a/web/src/composables/useContentTypes.ts b/web/src/composables/useContentTypes.ts index 83cffdc..7550043 100644 --- a/web/src/composables/useContentTypes.ts +++ b/web/src/composables/useContentTypes.ts @@ -1,12 +1,6 @@ import { useApi } from '@/composables/useApi' import { jsonInit } from '@/lib/directoryRequest' -import type { - BuiltinType, - ContentType, - EntryPage, - EntryPageQuery, - UserName, -} from '@/lib/contentTypes' +import type { BuiltinType, ContentType } from '@/lib/contentTypes' import type { ContentTypePayload, UpdatePayload } from '@/lib/schemaBuilderPayload' export type { @@ -75,29 +69,8 @@ export function useContentTypes() { return request<ContentType>(`/v1/content-type/${encodeURIComponent(key)}`, options) } - async function listEntries(key: string, query: EntryPageQuery): Promise<EntryPage> { - const params = new URLSearchParams({ - page: String(query.page), - size: String(query.size), - sort: query.sort, - }) - const url = `/v1/content-type/${encodeURIComponent(key)}/entry?${params.toString().replace(/%3A/g, ':')}` - const page = await request<EntryPage>(url, options) - if (!Array.isArray(page?.items) || typeof page.total !== 'number') { - throw new Error(`Unexpected response from ${url}`) - } - return page - } - - async function lookupUsers(ids: number[]): Promise<UserName[]> { - const distinct = [...new Set(ids)] - return requestList<UserName>(`/v1/user/lookup?ids=${distinct.join(',')}`) - } - return { getContentType, - listEntries, - lookupUsers, listContentTypes, listBuiltinTypes, createContentType, diff --git a/web/src/composables/useEntries.ts b/web/src/composables/useEntries.ts new file mode 100644 index 0000000..d61e23e --- /dev/null +++ b/web/src/composables/useEntries.ts @@ -0,0 +1,53 @@ +import { useApi } from '@/composables/useApi' +import type { ContentEntry, EntryPage, EntryPageQuery } from '@/lib/contentTypes' +import { entryEndpoint } from '@/lib/contentEndpoints' +import { jsonInit } from '@/lib/directoryRequest' + +export function useEntries() { + const { request } = useApi() + const options = { onForbidden: 'passthrough' } as const + + async function listEntries(key: string, query: EntryPageQuery): Promise<EntryPage> { + const params = new URLSearchParams({ + page: String(query.page), + size: String(query.size), + sort: query.sort, + }) + const url = `${entryEndpoint(key)}?${params.toString().replace(/%3A/g, ':')}` + const page = await request<EntryPage>(url, options) + if (!Array.isArray(page?.items) || typeof page.total !== 'number') { + throw new Error(`Unexpected response from ${url}`) + } + return page + } + + async function requestEntry(url: string, init: RequestInit): Promise<ContentEntry> { + const entry = await request<ContentEntry>(url, { ...init, ...options }) + if ( + typeof entry?.id !== 'number' || + typeof entry.data !== 'object' || + entry.data === null + ) { + throw new Error(`Unexpected response from ${url}`) + } + return entry + } + + function getEntry(key: string, id: number) { + return requestEntry(entryEndpoint(key, id), {}) + } + + function createEntry(key: string, data: Record<string, unknown>) { + return requestEntry(entryEndpoint(key), jsonInit('POST', { data })) + } + + function saveEntry(key: string, id: number, data: Record<string, unknown>) { + return requestEntry(entryEndpoint(key, id), jsonInit('PUT', { data })) + } + + async function deleteEntry(key: string, id: number): Promise<void> { + await request<void>(entryEndpoint(key, id), { method: 'DELETE', ...options }) + } + + return { listEntries, getEntry, createEntry, saveEntry, deleteEntry } +} diff --git a/web/src/composables/useEntryDraft.ts b/web/src/composables/useEntryDraft.ts new file mode 100644 index 0000000..661eafa --- /dev/null +++ b/web/src/composables/useEntryDraft.ts @@ -0,0 +1,162 @@ +import { computed, ref, watch } from 'vue' +import { ApiError } from '@/composables/useApi' +import { useContentTypeDetail } from '@/composables/useContentTypeDetail' +import { useEntries } from '@/composables/useEntries' +import { useEntryForm } from '@/composables/useEntryForm' +import { useEntryLoad } from '@/composables/useEntryLoad' +import type { ContentEntry } from '@/lib/contentTypes' +import { resolveEditorPhase, type EntryTarget } from '@/lib/entryEditorState' +import { buildData, issueFor, validateEntry } from '@/lib/entryForm' +import { classifyRefusal, isGoneError, parseEntryRefusal } from '@/lib/entryServerError' + +const UNAUTHORIZED = 401 + +export type SaveFailure = 'none' | 'failed' + +export function useEntryDraft(typeKey: string, target: EntryTarget) { + const { createEntry, saveEntry } = useEntries() + const detail = useContentTypeDetail(() => typeKey) + const load = useEntryLoad(typeKey, target) + const fields = computed(() => detail.type.value?.fields ?? []) + const entryId = ref<number | null>(typeof target === 'number' ? target : null) + const creating = computed(() => entryId.value === null) + const form = useEntryForm( + () => fields.value, + () => creating.value, + ) + + const saving = ref(false) + const failure = ref<SaveFailure>('none') + const schemaChanged = ref(false) + const schemaField = ref('') + const savedAt = ref('') + const focusToken = ref(0) + let built = false + + const phase = computed(() => resolveEditorPhase(detail.status.value, load.status.value)) + const ready = computed(() => phase.value === 'ready') + const locked = computed(() => saving.value || schemaChanged.value) + const canSave = computed( + () => ready.value && !locked.value && (creating.value || form.dirty.value), + ) + + watch( + phase, + (next) => { + if (next === 'ready' && !built) { + form.reset(load.entry.value) + built = true + } + }, + { immediate: true }, + ) + + function applyRefusal(error: unknown) { + const outcome = classifyRefusal(fields.value, parseEntryRefusal(error)) + if (outcome.kind === 'schema') { + schemaChanged.value = true + schemaField.value = outcome.key + return true + } + if (outcome.kind === 'unknown') { + return false + } + form.rejectField(outcome.field.key, issueFor(outcome.field, outcome.rule)) + focusToken.value++ + return true + } + + function settleFailure(error: unknown) { + if (error instanceof ApiError && error.status === UNAUTHORIZED) { + return + } + if (isGoneError(error)) { + load.markGone(error) + return + } + if (applyRefusal(error)) { + return + } + failure.value = 'failed' + } + + function request(data: Record<string, unknown>): Promise<ContentEntry> { + return entryId.value === null + ? createEntry(typeKey, data) + : saveEntry(typeKey, entryId.value, data) + } + + async function save(): Promise<ContentEntry | null> { + if (!ready.value || locked.value) { + return null + } + failure.value = 'none' + const issues = validateEntry(fields.value, form.values.value) + if (Object.keys(issues).length > 0) { + form.reject(issues) + focusToken.value++ + return null + } + saving.value = true + try { + const answered = await request(buildData(fields.value, form.values.value)) + load.adopt(answered) + entryId.value = answered.id + savedAt.value = answered.updated_at + form.reset(answered) + return answered + } catch (error) { + settleFailure(error) + return null + } finally { + saving.value = false + } + } + + function change(key: string, value: unknown) { + form.setValue(key, value) + } + + function reload() { + schemaChanged.value = false + failure.value = 'none' + built = false + detail.retry() + return load.retry(entryId.value) + } + + function retryLoad() { + if (phase.value === 'typeError') { + return detail.retry() + } + return load.retry(entryId.value) + } + + load.start() + + return { + typeKey, + detail, + load, + fields, + form, + entryId, + saving, + failure, + schemaChanged, + schemaField, + savedAt, + focusToken, + phase, + ready, + creating, + locked, + canSave, + save, + change, + reload, + retryLoad, + } +} + +export type EntryDraft = ReturnType<typeof useEntryDraft> diff --git a/web/src/composables/useEntryEditorPage.ts b/web/src/composables/useEntryEditorPage.ts new file mode 100644 index 0000000..6459b7e --- /dev/null +++ b/web/src/composables/useEntryEditorPage.ts @@ -0,0 +1,150 @@ +import { computed } from 'vue' +import { useRouter } from 'vue-router' +import { useMediaQuery } from '@vueuse/core' +import { t } from '@/i18n' +import { useLocalizedToast } from '@/i18n/presentation' +import { useEntryDraft } from '@/composables/useEntryDraft' +import { useEntryNames } from '@/composables/useEntryNames' +import { useEntryRemoval } from '@/composables/useEntryRemoval' +import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard' +import { entryTitle } from '@/lib/entryForm' +import type { EntryTarget } from '@/lib/entryEditorState' +import { formatEntryStamp, formatSavedClock } from '@/lib/entryStamp' +import { contentPath, entryPath } from '@/lib/routePaths' + +const DESKTOP_QUERY = '(min-width: 48rem)' +const SAVED_TOAST_ID = 'entry-saved' +const MISSING_NAME = '–' + +export type EntryChipStatus = 'new' | 'saved' | 'dirty' | 'saving' | 'failed' | 'unsaved' + +export function useEntryEditorPage( + typeKey: string, + target: EntryTarget, + adoptRoute: (id: number | null) => void, +) { + const router = useRouter() + const toast = useLocalizedToast() + const isDesktop = useMediaQuery(DESKTOP_QUERY) + const editor = useEntryDraft(typeKey, target) + const { detail, load, form } = editor + const names = useEntryNames(() => load.entry.value) + + const typeName = computed(() => detail.type.value?.name ?? typeKey) + const title = computed(() => entryTitle(editor.fields.value, load.entry.value)) + const subtitle = computed(() => + editor.creating.value + ? t('{0} · Fields marked * are required.', [typeName.value]) + : t('{0} · Entry {1}', [typeName.value, editor.entryId.value!]), + ) + const removal = useEntryRemoval( + editor, + () => typeName.value, + () => title.value, + ) + + const chip = computed<EntryChipStatus>(() => { + if (editor.saving.value) { + return 'saving' + } + if (editor.schemaChanged.value || form.rejected.value) { + return 'unsaved' + } + if (editor.failure.value === 'failed') { + return 'failed' + } + if (form.dirty.value) { + return 'dirty' + } + return editor.creating.value ? 'new' : 'saved' + }) + const savedClock = computed(() => + editor.savedAt.value && chip.value === 'saved' + ? formatSavedClock(editor.savedAt.value) + : '', + ) + const entryLine = computed(() => + editor.creating.value ? t('Not saved yet') : `${typeKey} / ${editor.entryId.value}`, + ) + + function person(id: number | undefined, iso: string | undefined) { + const entry = load.entry.value + if (!entry || id === undefined || iso === undefined) { + return MISSING_NAME + } + return `${names.value[id] ?? MISSING_NAME} · ${formatEntryStamp(iso)}` + } + + const createdLine = computed(() => + person(load.entry.value?.created_by, load.entry.value?.created_at), + ) + const updatedLine = computed(() => + person(load.entry.value?.updated_by, load.entry.value?.updated_at), + ) + + useUnsavedChangesGuard({ + isDirty: () => editor.ready.value && form.dirty.value && !removal.done.value, + title: () => title.value, + }) + + function announceSaved(savedTitle: string) { + toast.add(() => ({ + id: SAVED_TOAST_ID, + color: 'success', + icon: 'i-lucide-circle-check', + title: t('Entry saved'), + description: t('"{0}" is up to date.', [savedTitle]), + })) + } + + async function submit() { + const wasCreating = editor.creating.value + const answered = await editor.save() + if (!answered) { + return + } + announceSaved(entryTitle(editor.fields.value, answered)) + if (wasCreating) { + adoptRoute(answered.id) + const refused = await router.replace(entryPath(typeKey, answered.id)).then( + (failure) => Boolean(failure), + () => true, + ) + if (refused) { + adoptRoute(null) + } + } + } + + function cameFromList() { + const previous = (window.history.state as { back?: string | null } | null)?.back + return typeof previous === 'string' && previous.split('?')[0] === contentPath(typeKey) + } + + function cancel() { + if (cameFromList()) { + router.back() + return + } + router.push(contentPath(typeKey)) + } + + return { + ...editor, + isDesktop, + names, + typeName, + title, + subtitle, + chip, + savedClock, + entryLine, + createdLine, + updatedLine, + removal, + submit, + cancel, + } +} + +export type EntryEditorPage = ReturnType<typeof useEntryEditorPage> diff --git a/web/src/composables/useEntryForm.ts b/web/src/composables/useEntryForm.ts new file mode 100644 index 0000000..f8f6eee --- /dev/null +++ b/web/src/composables/useEntryForm.ts @@ -0,0 +1,69 @@ +import { computed, ref } from 'vue' +import type { BuiltinField, ContentEntry } from '@/lib/contentTypes' +import { + carriesDefault, + firstIssueKey, + initialValues, + valuesDiffer, + type EntryIssues, + type EntryValues, + type FieldIssue, +} from '@/lib/entryForm' + +export function useEntryForm(fields: () => BuiltinField[], creating: () => boolean) { + const values = ref<EntryValues>({}) + const saved = ref<EntryValues>({}) + const issues = ref<EntryIssues>({}) + const rejected = ref(false) + + const dirty = computed(() => valuesDiffer(fields(), values.value, saved.value)) + const firstIssue = computed(() => firstIssueKey(fields(), issues.value)) + const hasIssues = computed(() => Object.keys(issues.value).length > 0) + + function reset(entry: ContentEntry | null) { + const next = initialValues(fields(), entry) + values.value = { ...next } + saved.value = { ...next } + issues.value = {} + rejected.value = false + } + + function setValue(key: string, value: unknown) { + values.value = { ...values.value, [key]: value } + rejected.value = false + if (issues.value[key]) { + const rest = { ...issues.value } + delete rest[key] + issues.value = rest + } + } + + function reject(next: EntryIssues) { + issues.value = next + rejected.value = true + } + + function rejectField(key: string, issue: FieldIssue) { + reject({ [key]: issue }) + } + + function isDefault(field: BuiltinField) { + return creating() && carriesDefault(field, values.value[field.key]) + } + + return { + values, + issues, + rejected, + dirty, + firstIssue, + hasIssues, + reset, + setValue, + reject, + rejectField, + isDefault, + } +} + +export type EntryForm = ReturnType<typeof useEntryForm> diff --git a/web/src/composables/useEntryList.ts b/web/src/composables/useEntryList.ts index 8cd33ae..5b86e4e 100644 --- a/web/src/composables/useEntryList.ts +++ b/web/src/composables/useEntryList.ts @@ -1,7 +1,8 @@ import { ref } from 'vue' import { useRoute } from 'vue-router' import { ApiError } from '@/composables/useApi' -import { useContentTypes } from '@/composables/useContentTypes' +import { useEntries } from '@/composables/useEntries' +import { useUserLookup } from '@/composables/useUserLookup' import type { ContentEntry, EntryListStatus } from '@/lib/contentTypes' import { usePagedQuery, type PagedOutcome } from '@/composables/usePagedQuery' import { @@ -20,7 +21,8 @@ const FAILURES: Record<number, EntryListStatus> = { 403: 'forbidden', 404: 'miss export function useEntryList(typeKey: string) { const route = useRoute() - const { listEntries, lookupUsers } = useContentTypes() + const { listEntries } = useEntries() + const { lookupUsers } = useUserLookup() const items = ref<ContentEntry[]>([]) const status = ref<EntryListStatus>('loading') diff --git a/web/src/composables/useEntryLoad.ts b/web/src/composables/useEntryLoad.ts new file mode 100644 index 0000000..c6c5950 --- /dev/null +++ b/web/src/composables/useEntryLoad.ts @@ -0,0 +1,68 @@ +import { ref } from 'vue' +import { ApiError } from '@/composables/useApi' +import { useEntries } from '@/composables/useEntries' +import type { ContentEntry } from '@/lib/contentTypes' +import { missingKind, type EntryLoadStatus, type EntryTarget } from '@/lib/entryEditorState' + +const UNAUTHORIZED = 401 +const FORBIDDEN = 403 +const NOT_FOUND = 404 + +export function useEntryLoad(typeKey: string, target: EntryTarget) { + const { getEntry } = useEntries() + const entry = ref<ContentEntry | null>(null) + const status = ref<EntryLoadStatus>( + target === 'new' ? 'idle' : target === 'invalid' ? 'missing' : 'loading', + ) + let generation = 0 + + function fail(error: unknown) { + if (error instanceof ApiError && error.status === UNAUTHORIZED) { + return + } + if (error instanceof ApiError && error.status === FORBIDDEN) { + status.value = 'forbidden' + } else if (error instanceof ApiError && error.status === NOT_FOUND) { + status.value = missingKind(error.body) + } else { + status.value = 'failed' + } + } + + async function load(id: number) { + const current = ++generation + status.value = 'loading' + try { + const loaded = await getEntry(typeKey, id) + if (current === generation) { + entry.value = loaded + status.value = 'ready' + } + } catch (error) { + if (current === generation) { + fail(error) + } + } + } + + function start() { + return typeof target === 'number' ? load(target) : Promise.resolve() + } + + function retry(id: number | null) { + return id === null ? Promise.resolve() : load(id) + } + + function adopt(next: ContentEntry) { + generation++ + entry.value = next + status.value = 'ready' + } + + function markGone(error: unknown) { + generation++ + fail(error) + } + + return { entry, status, start, retry, adopt, markGone } +} diff --git a/web/src/composables/useEntryNames.ts b/web/src/composables/useEntryNames.ts new file mode 100644 index 0000000..b6c635e --- /dev/null +++ b/web/src/composables/useEntryNames.ts @@ -0,0 +1,34 @@ +import { ref, watch } from 'vue' +import { useUserLookup } from '@/composables/useUserLookup' +import type { ContentEntry } from '@/lib/contentTypes' + +function authorIds(entry: ContentEntry | null) { + return entry ? [entry.created_by, entry.updated_by].filter((id) => id > 0) : [] +} + +export function useEntryNames(entry: () => ContentEntry | null) { + const { lookupUsers } = useUserLookup() + const names = ref<Record<number, string>>({}) + const requested = new Set<number>() + + async function resolve() { + const wanted = [...new Set(authorIds(entry()))].filter((id) => !requested.has(id)) + if (wanted.length === 0) { + return + } + wanted.forEach((id) => requested.add(id)) + try { + const users = await lookupUsers(wanted) + names.value = { + ...names.value, + ...Object.fromEntries(users.map((user) => [user.id, user.display_name])), + } + } catch { + wanted.forEach((id) => requested.delete(id)) + } + } + + watch(() => authorIds(entry()).join(','), resolve, { immediate: true }) + + return names +} diff --git a/web/src/composables/useEntryRemoval.ts b/web/src/composables/useEntryRemoval.ts new file mode 100644 index 0000000..b6af8eb --- /dev/null +++ b/web/src/composables/useEntryRemoval.ts @@ -0,0 +1,90 @@ +import { ref } from 'vue' +import { useRouter } from 'vue-router' +import { t } from '@/i18n' +import { useLocalizedToast } from '@/i18n/presentation' +import { ApiError } from '@/composables/useApi' +import { useEntries } from '@/composables/useEntries' +import type { EntryDraft } from '@/composables/useEntryDraft' +import { isGoneError } from '@/lib/entryServerError' +import { contentPath } from '@/lib/routePaths' + +const UNAUTHORIZED = 401 +const TOAST_ID = 'entry-deleted' + +export type RemovalStage = 'closed' | 'confirm' | 'failed' + +export function useEntryRemoval(editor: EntryDraft, typeName: () => string, title: () => string) { + const router = useRouter() + const toast = useLocalizedToast() + const { deleteEntry } = useEntries() + + const stage = ref<RemovalStage>('closed') + const pending = ref(false) + const done = ref(false) + + function open() { + if (!pending.value && !editor.saving.value && editor.entryId.value !== null) { + stage.value = 'confirm' + } + } + + function close() { + if (!pending.value) { + stage.value = 'closed' + } + } + + function announce(deletedTitle: string) { + toast.add(() => ({ + id: TOAST_ID, + color: 'success', + icon: 'i-lucide-circle-check', + title: t('Entry deleted'), + description: t('"{0}" was deleted from {1}.', [deletedTitle, typeName()]), + })) + } + + function refuse(error: unknown) { + if (error instanceof ApiError && error.status === UNAUTHORIZED) { + return + } + if (isGoneError(error)) { + stage.value = 'closed' + editor.load.markGone(error) + return + } + stage.value = 'failed' + } + + async function confirm() { + const id = editor.entryId.value + if (pending.value || id === null) { + return + } + const deletedTitle = title() + pending.value = true + stage.value = 'confirm' + try { + await deleteEntry(editor.typeKey, id) + } catch (error) { + pending.value = false + refuse(error) + return + } + pending.value = false + done.value = true + stage.value = 'closed' + announce(deletedTitle) + const arrived = await router.replace(contentPath(editor.typeKey)).then( + (failure) => !failure, + () => false, + ) + if (!arrived) { + done.value = false + } + } + + return { stage, pending, done, open, close, confirm } +} + +export type EntryRemoval = ReturnType<typeof useEntryRemoval> diff --git a/web/src/composables/useUserLookup.ts b/web/src/composables/useUserLookup.ts new file mode 100644 index 0000000..350036d --- /dev/null +++ b/web/src/composables/useUserLookup.ts @@ -0,0 +1,18 @@ +import { useApi } from '@/composables/useApi' +import type { UserName } from '@/lib/contentTypes' + +export function useUserLookup() { + const { request } = useApi() + + async function lookupUsers(ids: number[]): Promise<UserName[]> { + const distinct = [...new Set(ids)] + const url = `/v1/user/lookup?ids=${distinct.join(',')}` + const listed = await request<UserName[]>(url, { onForbidden: 'passthrough' }) + if (!Array.isArray(listed)) { + throw new Error(`Unexpected response from ${url}`) + } + return listed + } + + return { lookupUsers } +} diff --git a/web/src/i18n/contentMessages.spec.ts b/web/src/i18n/contentMessages.spec.ts index 4ad05d1..68b789a 100644 --- a/web/src/i18n/contentMessages.spec.ts +++ b/web/src/i18n/contentMessages.spec.ts @@ -17,7 +17,7 @@ const SOURCES = [ 'components/SortSelect.vue', 'lib/entryCells.ts', 'lib/entryListQuery.ts', - 'views/ContentListView.vue', + 'views/ContentListPage.vue', ] const SAME_IN_FRENCH = new Set(['ID']) diff --git a/web/src/i18n/entryMessages.spec.ts b/web/src/i18n/entryMessages.spec.ts new file mode 100644 index 0000000..95db81a --- /dev/null +++ b/web/src/i18n/entryMessages.spec.ts @@ -0,0 +1,58 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { messages } from './messages' + +const SOURCES = [ + 'components/ContentState.vue', + 'components/EntryActions.vue', + 'components/EntryControl.vue', + 'components/EntryDateField.vue', + 'components/EntryDeleteDialog.vue', + 'components/EntryEditor.vue', + 'components/EntryEditorSkeleton.vue', + 'components/EntryForm.vue', + 'components/EntryNotices.vue', + 'components/MarkdownField.vue', + 'composables/useEntryEditorPage.ts', + 'composables/useEntryRemoval.ts', + 'lib/entryForm.ts', + 'lib/entryMessages.ts', + 'lib/entryEditors.ts', + 'views/EntryEditorPage.vue', +] + +const SAME_IN_FRENCH = new Set<string>([]) + +const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g + +function keysIn(source: string) { + return [...source.matchAll(LITERAL)].map((match) => + (match[1] ?? match[2] ?? '').replace(/\\u0022/g, '"').replace(/\\(['"\\])/g, '$1'), + ) +} + +describe('Entry editor messages', () => { + const keys = SOURCES.flatMap((file) => + keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({ + file, + key, + })), + ) + + it('finds the strings of the screen', () => { + expect(keys.length).toBeGreaterThan(60) + }) + + it.each(keys.map(({ file, key }) => [file, key]))( + '%s: "%s" has its own French entry', + (_file, key) => { + const french = (messages as Record<string, string>)[key as string] + + expect(french).toMatch(/\S/) + if (!SAME_IN_FRENCH.has(key as string)) { + expect(french).not.toBe(key) + } + }, + ) +}) diff --git a/web/src/i18n/messages.ts b/web/src/i18n/messages.ts index f6a0b99..88e19e8 100644 --- a/web/src/i18n/messages.ts +++ b/web/src/i18n/messages.ts @@ -2530,6 +2530,84 @@ export const messages = { '{0}, sorted by last updated, newest first': '{0}, trié par dernière modification, plus récent en premier', '{0}, sorted by {1}, {2}': '{0}, trié par {1}, {2}', + 'Entry saved': 'Entrée enregistrée', + '\u0022{0}\u0022 is up to date.': '« {0} » est à jour.', + 'Entry deleted': 'Entrée supprimée', + '\u0022{0}\u0022 was deleted from {1}.': '« {0} » a été supprimée de {1}.', + 'Delete this entry?': 'Supprimer cette entrée ?', + "\u0022{0}\u0022 will be deleted from {1}. This can't be undone.": + '« {0} » sera supprimée de {1}. Cette action est irréversible.', + 'Delete entry': 'Supprimer l’entrée', + "Couldn't delete the entry": 'Impossible de supprimer l’entrée', + 'Something went wrong on our side. Nothing was deleted. Try again in a moment.': + 'Un problème est survenu de notre côté. Rien n’a été supprimé. Réessayez dans un instant.', + "Couldn't save the entry": 'Impossible d’enregistrer l’entrée', + "Couldn't load the entry": 'Impossible de charger l’entrée', + 'GET {0} failed. Check your connection and try again.': + 'GET {0} a échoué. Vérifiez votre connexion et réessayez.', + 'Not saved': 'Non enregistrée', + 'Not saved yet': 'Pas encore enregistrée', + Entry: 'Entrée', + 'Entry {0}': 'Entrée {0}', + 'Entry not found': 'Entrée introuvable', + 'It may have been deleted by someone else.': + 'Elle a peut-être été supprimée par quelqu’un d’autre.', + 'Back to {0}': 'Retour à {0}', + 'The fields of {0} changed': 'Les champs de {0} ont changé', + "A field was removed and a new required field, {0}, was added after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.": + 'Un champ a été supprimé et un nouveau champ obligatoire, {0}, a été ajouté après l’ouverture de ce formulaire. Rechargez le formulaire pour continuer. Ce que vous avez saisi ici ne peut pas être enregistré avec les anciens champs.', + 'Reload form': 'Recharger le formulaire', + 'Another entry already uses this {0}. Change it and save again.': + 'Une autre entrée utilise déjà ce champ ({0}). Modifiez-le et enregistrez à nouveau.', + 'Another entry already uses this choice. Change it and save again.': + 'Une autre entrée utilise déjà ce choix. Modifiez-le et enregistrez à nouveau.', + 'Another entry already uses this date. Change it and save again.': + 'Une autre entrée utilise déjà cette date. Modifiez-la et enregistrez à nouveau.', + 'Another entry already uses this {0}. Enter a different one.': + 'Une autre entrée utilise déjà ce champ ({0}). Saisissez une autre valeur.', + 'Another entry already uses this choice for {0}.': + 'Une autre entrée utilise déjà ce choix pour {0}.', + 'Another entry already uses this date for {0}.': + 'Une autre entrée utilise déjà cette date pour {0}.', + 'Each entry needs a different {0}.': 'Chaque entrée doit avoir une valeur différente ({0}).', + '{0} · Fields marked * are required.': '{0} · Les champs marqués * sont obligatoires.', + '{0} · Entry {1}': '{0} · Entrée {1}', + 'Fill in {0}.': 'Renseignez {0}.', + 'Pick a date for {0}.': 'Choisissez une date pour {0}.', + 'Choose an option for {0}.': 'Choisissez une option pour {0}.', + 'Choose yes or no for {0}.': 'Choisissez oui ou non pour {0}.', + '{0} must be text.': '{0} doit être du texte.', + '{0} must be a number.': '{0} doit être un nombre.', + '{0} must be yes or no.': '{0} doit être oui ou non.', + '{0} must be a date, like 2026-11-21.': '{0} doit être une date, par exemple 2026-11-21.', + '{0} must be one of the choices.': '{0} doit être l’un des choix.', + '{0} must be at least {1}.': '{0} doit être au moins {1}.', + '{0} must be at most {1}.': '{0} doit être au plus {1}.', + "{0} can't be before {1}.": '{0} ne peut pas être avant le {1}.', + "{0} can't be after {1}.": '{0} ne peut pas être après le {1}.', + '{0} needs at least {1} characters.': '{0} doit contenir au moins {1} caractères.', + '{0} can be up to {1} characters.': '{0} peut contenir jusqu’à {1} caractères.', + 'Choose one of: {0}.': 'Choisissez parmi : {0}.', + '{0} or {1}': '{0} ou {1}', + 'From {0} until {1}.': 'Du {0} au {1}.', + 'From {0}.': 'À partir du {0}.', + 'Until {0}.': 'Jusqu’au {0}.', + 'Between {0} and {1}.': 'Entre {0} et {1}.', + 'At least {0}.': 'Au moins {0}.', + 'At most {0}.': 'Au plus {0}.', + 'Markdown. Switch to Preview to check the result.': + 'Markdown. Passez à l’aperçu pour vérifier le résultat.', + 'Write in Markdown': 'Écrivez en Markdown', + 'Nothing to preview yet.': 'Rien à prévisualiser pour l’instant.', + Write: 'Écrire', + 'Pick a date': 'Choisir une date', + 'Open calendar': 'Ouvrir le calendrier', + 'Enter a number': 'Saisissez un nombre', + 'Choose an option': 'Choisissez une option', + On: 'Activé', + Off: 'Désactivé', + Heading: 'Titre', + List: 'Liste', id: 'identifiant', } as const diff --git a/web/src/lib/__tests__/entryCells.spec.ts b/web/src/lib/__tests__/entryCells.spec.ts index b4091a6..7e1e315 100644 --- a/web/src/lib/__tests__/entryCells.spec.ts +++ b/web/src/lib/__tests__/entryCells.spec.ts @@ -65,6 +65,9 @@ describe('richTextFirstLine', () => { ['![alt text](https://example.test/a.png)', 'alt text'], ['`code` and ~~gone~~', 'code and gone'], ['snake_case stays', 'snake_case stays'], + ['_lime_ plaster', 'lime plaster'], + ['Two days of _lime putty_.', 'Two days of lime putty.'], + ['(_lime_) and my_var_name', '(lime) and my_var_name'], ])('turns "%s" into "%s"', (markdown, expected) => { expect(richTextFirstLine(markdown)).toBe(expected) }) diff --git a/web/src/lib/__tests__/entryEditorState.spec.ts b/web/src/lib/__tests__/entryEditorState.spec.ts new file mode 100644 index 0000000..6237f5e --- /dev/null +++ b/web/src/lib/__tests__/entryEditorState.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { resolveEditorPhase, resolveEntryTarget } from '@/lib/entryEditorState' + +describe('resolveEntryTarget', () => { + it.each([ + ['new', 'new'], + ['42', 42], + ['abc', 'invalid'], + ['0', 0], + ['4294967296', 'invalid'], + ['4294967295', 4294967295], + ['-1', 'invalid'], + ['4.2', 'invalid'], + ['', 'invalid'], + ])('%s resolves to %s', (raw, target) => { + expect(resolveEntryTarget(raw)).toBe(target) + }) +}) + +describe('resolveEditorPhase', () => { + it.each([ + ['ready', 'ready', 'ready'], + ['ready', 'idle', 'ready'], + ['loading', 'ready', 'loading'], + ['ready', 'loading', 'loading'], + ['missing', 'ready', 'typeNotFound'], + ['ready', 'typeMissing', 'typeNotFound'], + ['ready', 'missing', 'entryNotFound'], + ['forbidden', 'ready', 'forbidden'], + ['ready', 'forbidden', 'forbidden'], + ['failed', 'loading', 'typeError'], + ['ready', 'failed', 'entryError'], + ] as const)('type %s and entry %s is %s', (type, entry, phase) => { + expect(resolveEditorPhase(type, entry)).toBe(phase) + }) +}) diff --git a/web/src/lib/__tests__/entryForm.spec.ts b/web/src/lib/__tests__/entryForm.spec.ts new file mode 100644 index 0000000..fe0b564 --- /dev/null +++ b/web/src/lib/__tests__/entryForm.spec.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest' +import type { BuiltinField, ContentEntry } from '@/lib/contentTypes' +import { + buildData, + carriesDefault, + entryTitle, + firstIssueKey, + initialValues, + validateEntry, + valuesDiffer, +} from '@/lib/entryForm' +import { groupFields } from '@/lib/entryLayout' +import { apiField, eventsFields } from './schemaFixtures' + +const fields = eventsFields as unknown as BuiltinField[] + +function entry(data: Record<string, unknown>, id = 42): ContentEntry { + return { + id, + content_type: 'events', + data, + created_by: 1, + updated_by: 1, + created_at: '2026-10-12T09:14:00', + updated_at: '2026-10-12T09:14:00', + } +} + +describe('initialValues', () => { + it('pre-fills defaults on create', () => { + expect(initialValues(fields, null)).toEqual({ + title: '', + starts_on: '', + ends_on: '', + online: false, + capacity: null, + category: '', + description: '', + }) + }) + + it('uses the stored values on edit and no default', () => { + const values = initialValues(fields, entry({ title: 'Lime', online: null, capacity: 18 })) + + expect(values).toMatchObject({ title: 'Lime', online: null, capacity: 18, ends_on: '' }) + }) +}) + +describe('buildData', () => { + it('sends one key per field with null for empty optional fields', () => { + const values = { + ...initialValues(fields, null), + title: 'Open day', + starts_on: '2026-11-21', + } + + expect(buildData(fields, values)).toEqual({ + title: 'Open day', + starts_on: '2026-11-21', + ends_on: null, + online: false, + capacity: null, + category: null, + description: null, + }) + }) + + it('never sends a key the type does not have', () => { + const stored = entry({ title: 'Lime', removed: 'x' }) + + expect(Object.keys(buildData(fields, initialValues(fields, stored)))).toEqual( + fields.map((field) => field.key), + ) + }) + + it.each([ + ['optional, no default', {}, undefined, null], + ['optional, loaded as null', { default: false }, null, null], + ['required, no default', { required: true }, undefined, false], + ['optional, default true', { default: true }, 'default', true], + ])('a boolean that was never set: %s', (_name, rest, stored, sent) => { + const field = apiField('Flag', 'flag', 'boolean', rest) as unknown as BuiltinField + const loaded = + stored === undefined ? null : stored === 'default' ? null : entry({ flag: null }) + const values = initialValues([field], stored === 'default' ? null : loaded) + + expect(buildData([field], values).flag).toBe(sent) + }) +}) + +describe('validateEntry', () => { + const base = initialValues(fields, null) + + it('reports every empty required field at once, in schema order', () => { + const issues = validateEntry(fields, base) + + expect(Object.keys(issues)).toEqual(['title', 'starts_on']) + expect(issues.title!.message).toBe('Fill in Title.') + expect(issues.starts_on!.message).toBe('Pick a date for Starts on.') + expect(firstIssueKey(fields, issues)).toBe('title') + }) + + it('reports one rule per field, with the message of the rule', () => { + const issues = validateEntry(fields, { + ...base, + title: 'x'.repeat(91), + starts_on: '2026-11-21', + capacity: 6000, + category: 'Seminar', + }) + + expect( + Object.fromEntries(Object.entries(issues).map(([key, issue]) => [key, issue.message])), + ).toEqual({ + title: 'Title can be up to 80 characters.', + capacity: 'Capacity must be at most 5000.', + category: 'Choose one of: Talk, Workshop or Meetup.', + }) + }) + + it.each([ + ['Title', 'text', { min: 3 }, 'ab', 'Title needs at least 3 characters.'], + ['Capacity', 'number', { min: 1 }, 0, 'Capacity must be at least 1.'], + [ + 'Starts on', + 'date', + { min: '2026-01-01' }, + '2025-12-31', + "Starts on can't be before 1 Jan 2026.", + ], + [ + 'Starts on', + 'date', + { max: '2027-12-31' }, + '2028-01-01', + "Starts on can't be after 31 Dec 2027.", + ], + ['Starts on', 'date', {}, '2026-02-30', 'Starts on must be a date, like 2026-11-21.'], + ])('%s (%s %j) holding %j reads "%s"', (label, type, rest, value, message) => { + const field = apiField(label, 'f', type, rest) as unknown as BuiltinField + + expect(validateEntry([field], { f: value }).f!.message).toBe(message) + }) + + it.each([ + ['text', 'Title', 'Fill in Title.'], + ['richtext', 'Notes', 'Fill in Notes.'], + ['number', 'Capacity', 'Fill in Capacity.'], + ['date', 'Starts on', 'Pick a date for Starts on.'], + ['select', 'Category', 'Choose an option for Category.'], + ])('required %s %s reads "%s"', (type, label, message) => { + const field = apiField(label, 'f', type, { required: true }) as unknown as BuiltinField + + expect(validateEntry([field], { f: type === 'number' ? null : '' }).f!.message).toBe( + message, + ) + }) +}) + +describe('entryTitle', () => { + it.each([ + ['Lime plaster workshop', 'text', 'Lime plaster workshop'], + [null, 'text', 'Entry 42'], + ['First line\n\nSecond', 'richtext', 'First line'], + [18, 'number', '18'], + ])('%j of a %s field reads %s', (value, type, title) => { + const first = apiField('First', 'first', type) as unknown as BuiltinField + + expect(entryTitle([first], entry({ first: value }))).toBe(title) + }) + + it('reads New entry without an entry', () => { + expect(entryTitle(fields, null)).toBe('New entry') + }) +}) + +describe('defaults and changes', () => { + it('tags a value equal to the default and drops the tag on change', () => { + const online = fields[3]! + + expect(carriesDefault(online, false)).toBe(true) + expect(carriesDefault(online, true)).toBe(false) + expect(carriesDefault(fields[0]!, '')).toBe(false) + }) + + it('differs only when a value changed, and not when it is restored', () => { + const saved = initialValues(fields, entry({ capacity: 18 })) + + expect(valuesDiffer(fields, { ...saved, capacity: 20 }, saved)).toBe(true) + expect(valuesDiffer(fields, { ...saved, capacity: 18 }, saved)).toBe(false) + }) +}) + +describe('groupFields', () => { + it('pairs dates, numbers and booleans, never text, rich text or select', () => { + expect(groupFields(fields).map((row) => row.map((field) => field.key))).toEqual([ + ['title'], + ['starts_on', 'ends_on'], + ['online', 'capacity'], + ['category'], + ['description'], + ]) + }) + + it('keeps a pairable field alone when the next one cannot pair', () => { + const loose = [ + { ...fields.find((field) => field.key === 'capacity')! }, + { ...fields.find((field) => field.key === 'title')! }, + { ...fields.find((field) => field.key === 'online')! }, + ] + + expect(groupFields(loose).map((row) => row.map((field) => field.key))).toEqual([ + ['capacity'], + ['title'], + ['online'], + ]) + }) +}) diff --git a/web/src/lib/__tests__/entryRules.spec.ts b/web/src/lib/__tests__/entryRules.spec.ts new file mode 100644 index 0000000..d3ed372 --- /dev/null +++ b/web/src/lib/__tests__/entryRules.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import type { BuiltinField } from '@/lib/contentTypes' +import { ENTRY_EDITORS } from '@/lib/entryEditors' +import { asNumber, freeSentinel, isIsoDay } from '@/lib/entryValue' +import { valuesDiffer } from '@/lib/entryForm' + +function field(type: string, rest: Partial<BuiltinField> = {}): BuiltinField { + return { + key: 'f', + label: 'Field', + type, + required: false, + unique: false, + default: null, + min: null, + max: null, + options: null, + ...rest, + } +} + +describe('length limits', () => { + const text = field('text', { min: 3, max: 5 }) + + it.each([ + ['ab', 'min'], + ['abc', null], + ['abcde', null], + ['abcdef', 'max'], + ])('%s is judged %s', (value, rule) => { + expect(ENTRY_EDITORS.text.validate(text, value)).toBe(rule) + }) + + it('flags the counter only once over the maximum', () => { + expect(ENTRY_EDITORS.text.counter(text, 'abcde')).toEqual({ text: '5 / 5', over: false }) + expect(ENTRY_EDITORS.text.counter(text, 'abcdef')).toEqual({ text: '6 / 5', over: true }) + }) +}) + +describe('number limits', () => { + const number = field('number', { min: 2, max: 4 }) + + it.each([ + [1, 'min'], + [2, null], + [4, null], + [5, 'max'], + ])('%s is judged %s', (value, rule) => { + expect(ENTRY_EDITORS.number.validate(number, value)).toBe(rule) + }) + + it('sends an empty string as null', () => { + expect(ENTRY_EDITORS.number.payload(number, '')).toBeNull() + expect(ENTRY_EDITORS.number.payload(number, 0)).toBe(0) + }) +}) + +describe('date limits', () => { + const date = field('date', { min: '2026-11-10', max: '2026-11-20' }) + + it.each([ + ['2026-11-09', 'min'], + ['2026-11-10', null], + ['2026-11-20', null], + ['2026-11-21', 'max'], + ['2026-02-30', 'type'], + ])('%s is judged %s', (value, rule) => { + expect(ENTRY_EDITORS.date.validate(date, value)).toBe(rule) + }) +}) + +describe('value helpers', () => { + it('rejects a day that does not exist', () => { + expect(isIsoDay('2026-02-28')).toBe(true) + expect(isIsoDay('2026-02-30')).toBe(false) + expect(isIsoDay('2026-13-01')).toBe(false) + }) + + it('reads blank strings as no number', () => { + expect(asNumber(' ')).toBeNull() + expect(asNumber(' 7 ')).toBe(7) + expect(asNumber('0')).toBe(0) + }) +}) + +describe('freeSentinel', () => { + it('returns the base when free and extends it until it is not taken', () => { + expect(freeSentinel(['a'])).toBe('__none__') + expect(freeSentinel(['__none__'])).toBe('__none___') + expect(freeSentinel(['__none__', '__none___'])).toBe('__none____') + }) +}) + +describe('valuesDiffer', () => { + it('compares each field by identity', () => { + const fields = [field('number')] + + expect(valuesDiffer(fields, { f: 0 }, { f: 0 })).toBe(false) + expect(valuesDiffer(fields, { f: 0 }, { f: null })).toBe(true) + expect(valuesDiffer(fields, { f: '' }, { f: null })).toBe(true) + }) +}) diff --git a/web/src/lib/__tests__/entryServerError.spec.ts b/web/src/lib/__tests__/entryServerError.spec.ts new file mode 100644 index 0000000..fdcaa7e --- /dev/null +++ b/web/src/lib/__tests__/entryServerError.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { ApiError } from '@/composables/useApi' +import type { BuiltinField } from '@/lib/contentTypes' +import { classifyRefusal, isGoneError, parseEntryRefusal } from '@/lib/entryServerError' +import { eventsFields } from './schemaFixtures' + +const fields = eventsFields as unknown as BuiltinField[] + +function refusal(status: number, body: string) { + return new ApiError(status, undefined, body) +} + +describe('parseEntryRefusal', () => { + it.each([ + [422, 'invalid_entry: title: required', { key: 'title', rule: 'required', status: 422 }], + [409, 'invalid_entry: title: unique', { key: 'title', rule: 'unique', status: 409 }], + [ + 422, + 'invalid_entry: room: unknown_field', + { key: 'room', rule: 'unknown_field', status: 422 }, + ], + ])('%i %s', (status, body, parsed) => { + expect(parseEntryRefusal(refusal(status, body))).toEqual(parsed) + }) + + it.each([ + [422, 'invalid_entry'], + [400, 'invalid_entry: title: required'], + [500, 'boom'], + [422, ''], + ])('ignores %i "%s"', (status, body) => { + expect(parseEntryRefusal(refusal(status, body))).toBeNull() + }) + + it('ignores anything that is not an api error', () => { + expect(parseEntryRefusal(new Error('x'))).toBeNull() + }) +}) + +describe('classifyRefusal', () => { + it.each([ + ['title', 'required', 'field'], + ['capacity', 'max', 'field'], + ['category', 'options', 'field'], + ['room', 'required', 'schema'], + ['room', 'unique', 'schema'], + ['description', 'unknown_field', 'schema'], + ['colour', 'unknown_field', 'schema'], + ])('%s %s is %s', (key, rule, kind) => { + const outcome = classifyRefusal(fields, { key, rule, status: 422 }) + + expect(outcome.kind).toBe(kind) + }) + + it('carries the field of a field outcome and the key of a schema outcome', () => { + const title = fields.find((field) => field.key === 'title')! + + expect(classifyRefusal(fields, { key: 'title', rule: 'min', status: 422 })).toEqual({ + kind: 'field', + field: title, + rule: 'min', + status: 422, + }) + expect(classifyRefusal(fields, { key: 'room', rule: 'required', status: 422 })).toEqual({ + kind: 'schema', + key: 'room', + }) + }) + + it('has no outcome without a refusal', () => { + expect(classifyRefusal(fields, null)).toEqual({ kind: 'unknown' }) + }) +}) + +describe('isGoneError', () => { + it.each([ + [403, true], + [404, true], + [401, false], + [409, false], + [500, false], + ])('reads status %i as %s', (status, gone) => { + expect(isGoneError(new ApiError(status))).toBe(gone) + }) + + it('ignores errors that are not API errors', () => { + expect(isGoneError(new Error('boom'))).toBe(false) + expect(isGoneError(undefined)).toBe(false) + }) +}) diff --git a/web/src/lib/__tests__/fieldTypes.spec.ts b/web/src/lib/__tests__/fieldTypes.spec.ts new file mode 100644 index 0000000..2872eab --- /dev/null +++ b/web/src/lib/__tests__/fieldTypes.spec.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' +import { FIELD_TYPES, fieldTypeSpec, type FieldType } from '@/lib/fieldTypes' +import { apiField } from './schemaFixtures' + +const CONTROLS: Record<FieldType, string> = { + text: 'input', + richtext: 'markdown', + number: 'number', + boolean: 'switch', + date: 'date', + select: 'select', +} + +function editor(type: FieldType) { + return fieldTypeSpec(type).editor +} + +describe('field type registry', () => { + it.each(FIELD_TYPES)('%s has an icon, a label, a description and a control', (type) => { + const spec = fieldTypeSpec(type) + + expect(spec.icon).toMatch(/^i-lucide-/) + expect(spec.label()).not.toBe('') + expect(spec.description()).not.toBe('') + expect(spec.editor.control).toBe(CONTROLS[type]) + }) + + it('falls back to text for an unknown type', () => { + expect(fieldTypeSpec('mystery')).toBe(fieldTypeSpec('text')) + }) + + it.each([ + ['text', ''], + ['richtext', ''], + ['number', null], + ['boolean', null], + ['date', ''], + ['select', ''], + ] as const)( + '%s starts empty on create without a default and from null on edit', + (type, empty) => { + const field = apiField('Field', 'field', type) + + expect(editor(type).initial(field, undefined, true)).toBe(empty) + expect(editor(type).initial(field, null, false)).toBe(empty) + }, + ) + + it.each([ + ['text', 'Untitled', 'Untitled'], + ['richtext', '# Hello', '# Hello'], + ['number', 100, 100], + ['boolean', true, true], + ['boolean', false, false], + ['date', '2026-11-21', '2026-11-21'], + ['select', 'Talk', 'Talk'], + ] as const)('%s pre-fills its default %j on create only', (type, given, shown) => { + const field = apiField('Field', 'field', type, { default: given }) + + expect(editor(type).initial(field, undefined, true)).toBe(shown) + expect(editor(type).initial(field, null, false)).not.toBe(shown) + }) + + it.each([ + ['text', { unique: true, label: 'Title' }, 'Each entry needs a different title.'], + ['text', {}, ''], + ['number', { min: 1, max: 5000 }, 'Between 1 and 5000.'], + ['number', { min: 1 }, 'At least 1.'], + ['number', { max: 5000 }, 'At most 5000.'], + ['date', { min: '2026-01-01', max: '2027-12-31' }, 'From 1 Jan 2026 until 31 Dec 2027.'], + ['richtext', {}, 'Markdown. Switch to Preview to check the result.'], + ['boolean', {}, ''], + ['select', {}, ''], + ] as const)('%s help line for %j reads "%s"', (type, rest, help) => { + const field = apiField(rest.label ?? 'Field', 'field', type, rest) + + expect(editor(type).help(field)).toBe(help) + }) + + it.each(['text', 'richtext'] as const)('%s counts characters when it has a max', (type) => { + const field = apiField('Field', 'field', type, { max: 80 }) + + expect(editor(type).counter(field, 'a'.repeat(32))).toEqual({ + text: '32 / 80', + over: false, + }) + expect(editor(type).counter(field, 'a'.repeat(91))).toEqual({ + text: '91 / 80', + over: true, + }) + expect(editor(type).counter(apiField('F', 'f', type), 'abc')).toBeNull() + expect(editor(type).counter(field, '😀😀')?.text).toBe('2 / 80') + }) + + it.each([ + ['text', { required: true }, '', 'required'], + ['text', { min: 3 }, 'ab', 'min'], + ['text', { max: 3 }, 'abcd', 'max'], + ['text', { min: 3 }, '', null], + ['richtext', { required: true }, null, 'required'], + ['number', { required: true }, null, 'required'], + ['number', { min: 1 }, 0, 'min'], + ['number', { max: 5000 }, 6000, 'max'], + ['number', {}, 0, null], + ['boolean', { required: true }, null, null], + ['boolean', {}, 'yes', 'type'], + ['date', { required: true }, '', 'required'], + ['date', { min: '2026-01-01' }, '2025-12-31', 'min'], + ['date', { max: '2027-12-31' }, '2028-01-01', 'max'], + ['date', {}, '2026-02-30', 'type'], + ['select', { required: true }, '', 'required'], + ['select', { options: ['Talk'] }, 'Seminar', 'options'], + ['select', { options: ['Talk'] }, 'Talk', null], + ] as const)('%s %j holding %j gives %s', (type, rest, value, rule) => { + const field = apiField('Field', 'field', type, rest) + + expect(editor(type).validate(field, value)).toBe(rule) + }) + + it.each([ + ['text', {}, '', null], + ['text', {}, ' kept ', ' kept '], + ['richtext', {}, '', null], + ['number', {}, null, null], + ['number', {}, 0, 0], + ['boolean', {}, null, null], + ['boolean', { required: true }, null, false], + ['boolean', {}, true, true], + ['date', {}, '', null], + ['date', {}, '2026-11-21', '2026-11-21'], + ['select', {}, '', null], + ['select', {}, 'Talk', 'Talk'], + ] as const)('%s %j holding %j sends %j', (type, rest, value, sent) => { + const field = apiField('Field', 'field', type, rest) + + expect(editor(type).payload(field, value)).toBe(sent) + }) + + it.each([ + ['text', true], + ['richtext', false], + ['number', true], + ['boolean', false], + ['date', true], + ['select', true], + ] as const)('%s unique support is %s', (type, unique) => { + expect(fieldTypeSpec(type).supportsUnique).toBe(unique) + }) +}) diff --git a/web/src/lib/__tests__/markdown.spec.ts b/web/src/lib/__tests__/markdown.spec.ts new file mode 100644 index 0000000..3587b90 --- /dev/null +++ b/web/src/lib/__tests__/markdown.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { renderMarkdown } from '@/lib/markdown' +import { applyMarkdownAction } from '@/lib/markdownEdit' + +describe('renderMarkdown', () => { + it('renders bold text and a bulleted list', () => { + const html = renderMarkdown( + 'Two days with **lime putty**.\n\n- Bring work clothes\n- Lunch', + ) + + expect(html).toContain('<strong>lime putty</strong>') + expect(html.match(/<li>/g)).toHaveLength(2) + }) + + it('shows raw HTML as text', () => { + const html = renderMarkdown('<script>alert(1)</script>') + + expect(html).not.toContain('<script>') + expect(html).toContain('<script>') + }) + + it.each(['https://example.com', 'http://example.com', 'mailto:a@b.co'])( + 'keeps the link %s active', + (url) => { + expect(renderMarkdown(`[x](${url})`)).toContain(`href="${url}"`) + }, + ) + + it.each(['javascript:alert(1)', 'data:text/html,x', 'vbscript:x', '/relative', 'url'])( + 'leaves the link %s inert', + (url) => { + expect(renderMarkdown(`[x](${url})`)).not.toContain('<a ') + }, + ) + + it('opens links in a new tab without leaking the opener', () => { + const html = renderMarkdown('[x](https://example.com)') + + expect(html).toContain('target="_blank"') + expect(html).toContain('rel="noopener noreferrer"') + }) + + it('does not load images', () => { + expect(renderMarkdown('![x](https://example.com/a.png)')).not.toContain('<img') + }) +}) + +describe('applyMarkdownAction', () => { + it.each([ + ['bold', '**lime**'], + ['italic', '_lime_'], + ['heading', '## lime'], + ['link', '[lime](url)'], + ['list', '- lime'], + ] as const)('%s turns the selection into %s', (action, result) => { + expect(applyMarkdownAction(action, 'lime', 0, 4).value).toBe(result) + }) + + it('selects the url placeholder of a link', () => { + const edit = applyMarkdownAction('link', 'see lime now', 4, 8) + + expect(edit.value.slice(edit.start, edit.end)).toBe('url') + }) + + it('prefixes every selected line of a list', () => { + expect(applyMarkdownAction('list', 'a\nb', 0, 3).value).toBe('- a\n- b') + }) + + it('puts the heading mark at the start of the line', () => { + expect(applyMarkdownAction('heading', 'one\ntwo lime', 8, 12).value).toBe( + 'one\n## two lime', + ) + }) + + it('moves the selection past the list marker of the first line', () => { + const edit = applyMarkdownAction('list', 'a\nb', 0, 3) + + expect([edit.start, edit.end]).toEqual([2, 7]) + }) + + it('prefixes only the current line when the cursor sits right after a line break', () => { + expect(applyMarkdownAction('list', 'one\ntwo', 4, 4).value).toBe('one\n- two') + expect(applyMarkdownAction('heading', 'one\ntwo', 4, 4).value).toBe('one\n## two') + }) + + it('prefixes the line the cursor ends, not the next one', () => { + expect(applyMarkdownAction('heading', 'one\ntwo', 3, 3).value).toBe('## one\ntwo') + }) + + it('prefixes the first line when it is empty and the caret sits at the start', () => { + expect(applyMarkdownAction('heading', '\nfoo', 0, 0).value).toBe('## \nfoo') + expect(applyMarkdownAction('list', '\nfoo', 0, 0).value).toBe('- \nfoo') + }) +}) diff --git a/web/src/lib/__tests__/schemaFields.spec.ts b/web/src/lib/__tests__/schemaFields.spec.ts index ce025f1..b711063 100644 --- a/web/src/lib/__tests__/schemaFields.spec.ts +++ b/web/src/lib/__tests__/schemaFields.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { BuiltinField } from '@/composables/useContentTypes' -import { fieldIcon, fieldRules, fieldTypeLabel } from '@/lib/schemaFields' +import { fieldTypeSpec } from '@/lib/fieldTypes' +import { fieldRules } from '@/lib/schemaFields' function field(overrides: Partial<BuiltinField>): BuiltinField { return { @@ -26,8 +27,8 @@ describe('field types', () => { ['date', 'Date', 'i-lucide-calendar'], ['select', 'Choice', 'i-lucide-list'], ])('%s reads %s with %s', (type, label, icon) => { - expect(fieldTypeLabel(type)).toBe(label) - expect(fieldIcon(type)).toBe(icon) + expect(fieldTypeSpec(type).label()).toBe(label) + expect(fieldTypeSpec(type).icon).toBe(icon) }) }) diff --git a/web/src/lib/apiError.ts b/web/src/lib/apiError.ts new file mode 100644 index 0000000..d7e14a3 --- /dev/null +++ b/web/src/lib/apiError.ts @@ -0,0 +1,12 @@ +export class ApiError extends Error { + status: number + retryAfter?: number + body?: string + + constructor(status: number, retryAfter?: number, body?: string) { + super(`Request failed with status ${status}`) + this.status = status + this.retryAfter = retryAfter + this.body = body + } +} diff --git a/web/src/lib/contentEndpoints.ts b/web/src/lib/contentEndpoints.ts new file mode 100644 index 0000000..4ad5a7d --- /dev/null +++ b/web/src/lib/contentEndpoints.ts @@ -0,0 +1,8 @@ +export function typeEndpoint(key: string) { + return `/v1/content-type/${encodeURIComponent(key)}` +} + +export function entryEndpoint(key: string, id?: number) { + const base = `${typeEndpoint(key)}/entry` + return id === undefined ? base : `${base}/${id}` +} diff --git a/web/src/lib/contentNav.ts b/web/src/lib/contentNav.ts index 2a78b52..b013529 100644 --- a/web/src/lib/contentNav.ts +++ b/web/src/lib/contentNav.ts @@ -1,4 +1,4 @@ -import type { ContentType } from '@/composables/useContentTypes' +import type { ContentType } from '@/lib/contentTypes' export const COLLAPSED_LIMIT = 5 diff --git a/web/src/lib/contentTypes.ts b/web/src/lib/contentTypes.ts index 51a0924..acb939e 100644 --- a/web/src/lib/contentTypes.ts +++ b/web/src/lib/contentTypes.ts @@ -5,8 +5,8 @@ export interface BuiltinField { required: boolean unique: boolean default: unknown - min: number | null - max: number | null + min: number | string | null + max: number | string | null options: unknown[] | null } @@ -22,7 +22,7 @@ export interface ContentType { export interface ContentEntry { id: number - content_type_id: number + content_type: string data: Record<string, unknown> created_by: number updated_by: number @@ -35,7 +35,6 @@ export interface EntryPage { total: number page: number size: number - sort: string } export interface EntryPageQuery { diff --git a/web/src/lib/entryCells.ts b/web/src/lib/entryCells.ts index 7ff0458..423d669 100644 --- a/web/src/lib/entryCells.ts +++ b/web/src/lib/entryCells.ts @@ -1,8 +1,12 @@ import { formatNumber, t } from '@/i18n' import type { BuiltinField } from '@/lib/contentTypes' -import { formatAbsoluteDate, formatAbsoluteDateFromIso } from '@/lib/relativeDate' +import { fieldTypeSpec, type FieldCellKind } from '@/lib/fieldTypes' +import { formatDayValue } from '@/lib/entryValue' +import { formatAbsoluteDateFromIso } from '@/lib/relativeDate' -export type CellKind = 'empty' | 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'choice' +export { formatDayValue } + +export type CellKind = 'empty' | FieldCellKind export interface CellView { kind: CellKind @@ -13,10 +17,10 @@ export interface CellView { export const EMPTY_TEXT = '–' const EMPTY: CellView = { kind: 'empty', text: '' } -const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})/ const LINE_PREFIX = /^\s{0,3}(?:#{1,6}(?:\s+|$)|>\s?|[-*+]\s+|\d+[.)]\s+)+/ const IMAGE_OR_LINK = /!?\[([^\]]*)\]\([^)]*\)/g const EMPHASIS = /(\*\*|__|\*|`|~~)/g +const UNDERSCORE_EMPHASIS = /(^|[\s(])_(?=\S)([^_]*?\S)_(?=$|[\s).,;:!?])/g export function richTextFirstLine(markdown: string) { for (const line of markdown.split(/\r?\n/)) { @@ -24,6 +28,7 @@ export function richTextFirstLine(markdown: string) { .replace(LINE_PREFIX, '') .replace(IMAGE_OR_LINK, '$1') .replace(EMPHASIS, '') + .replace(UNDERSCORE_EMPHASIS, '$1$2') .trim() if (plain) { return plain @@ -32,35 +37,26 @@ export function richTextFirstLine(markdown: string) { return '' } -export function formatDayValue(value: string) { - const match = ISO_DAY.exec(value) - if (!match) { - return value - } - return formatAbsoluteDate(new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))) -} - export function describeCell(field: BuiltinField, value: unknown): CellView { if (value === undefined || value === null || value === '') { return EMPTY } - switch (field.type) { + const kind = fieldTypeSpec(field.type).cell + switch (kind) { case 'boolean': - return { kind: 'boolean', text: '', yes: value === true } + return { kind, text: '', yes: value === true } case 'number': - return typeof value === 'number' - ? { kind: 'number', text: formatNumber(value) } - : { kind: 'number', text: String(value) } + return { kind, text: typeof value === 'number' ? formatNumber(value) : String(value) } case 'date': - return { kind: 'date', text: formatDayValue(String(value)) } - case 'select': - return { kind: 'choice', text: String(value) } + return { kind, text: formatDayValue(String(value)) } + case 'choice': + return { kind, text: String(value) } case 'richtext': { const line = richTextFirstLine(String(value)) - return line ? { kind: 'richtext', text: line } : EMPTY + return line ? { kind, text: line } : EMPTY } default: - return { kind: 'text', text: String(value) } + return { kind, text: String(value) } } } diff --git a/web/src/lib/entryEditorState.ts b/web/src/lib/entryEditorState.ts new file mode 100644 index 0000000..597c061 --- /dev/null +++ b/web/src/lib/entryEditorState.ts @@ -0,0 +1,66 @@ +import type { ContentTypeDetailStatus } from '@/lib/contentTypes' + +export type EntryLoadStatus = + | 'idle' + | 'loading' + | 'ready' + | 'failed' + | 'forbidden' + | 'missing' + | 'typeMissing' + +export type EntryEditorPhase = + | 'loading' + | 'ready' + | 'typeNotFound' + | 'entryNotFound' + | 'forbidden' + | 'typeError' + | 'entryError' + +const CONTENT_TYPE_MISSING_BODY = 'content_type_not_found' + +export function missingKind(body: string | undefined): 'missing' | 'typeMissing' { + return body === CONTENT_TYPE_MISSING_BODY ? 'typeMissing' : 'missing' +} + +export function resolveEditorPhase( + type: ContentTypeDetailStatus, + entry: EntryLoadStatus, +): EntryEditorPhase { + if (type === 'missing' || entry === 'typeMissing') { + return 'typeNotFound' + } + if (entry === 'missing') { + return 'entryNotFound' + } + if (type === 'forbidden' || entry === 'forbidden') { + return 'forbidden' + } + if (type === 'failed') { + return 'typeError' + } + if (entry === 'failed') { + return 'entryError' + } + if (type === 'loading' || entry === 'loading') { + return 'loading' + } + return 'ready' +} + +export type EntryTarget = number | 'new' | 'invalid' + +const DIGITS = /^\d+$/ +const UINT32_MAX = 4294967295 + +export function resolveEntryTarget(raw: string): EntryTarget { + if (raw === 'new') { + return 'new' + } + if (!DIGITS.test(raw)) { + return 'invalid' + } + const id = Number(raw) + return id >= 0 && id <= UINT32_MAX ? id : 'invalid' +} diff --git a/web/src/lib/entryEditors.ts b/web/src/lib/entryEditors.ts new file mode 100644 index 0000000..e9268ee --- /dev/null +++ b/web/src/lib/entryEditors.ts @@ -0,0 +1,172 @@ +import { t } from '@/i18n' +import type { BuiltinField } from '@/lib/contentTypes' +import { rangeHelp, uniqueHelp, type EntryRule } from '@/lib/entryMessages' +import { asBoolean, asNumber, asText, charCount, isBlank, isIsoDay } from '@/lib/entryValue' +import type { FieldType } from '@/lib/fieldTypes' + +export type FieldControl = 'input' | 'markdown' | 'number' | 'switch' | 'date' | 'select' + +export interface FieldCounter { + text: string + over: boolean +} + +export interface EntryEditorSpec { + control: FieldControl + initial: (field: BuiltinField, stored: unknown, creating: boolean) => unknown + validate: (field: BuiltinField, value: unknown) => EntryRule | null + payload: (field: BuiltinField, value: unknown) => unknown + help: (field: BuiltinField) => string + counter: (field: BuiltinField, value: unknown) => FieldCounter | null +} + +type Limit = number | string | null + +function textInitial(field: BuiltinField, stored: unknown, creating: boolean) { + return asText(creating ? field.default : stored) +} + +function textPayload(_field: BuiltinField, value: unknown) { + return isBlank(value) ? null : String(value) +} + +function lengthViolation(field: BuiltinField, value: unknown): EntryRule | null { + if (isBlank(value)) { + return field.required ? 'required' : null + } + const length = charCount(String(value)) + if (typeof field.min === 'number' && length < field.min) { + return 'min' + } + return typeof field.max === 'number' && length > field.max ? 'max' : null +} + +function lengthCounter(field: BuiltinField, value: unknown): FieldCounter | null { + if (typeof field.max !== 'number') { + return null + } + const length = charCount(asText(value)) + return { text: `${length} / ${field.max}`, over: length > field.max } +} + +function textHelp(field: BuiltinField) { + return field.unique ? uniqueHelp(field) : '' +} + +function numberViolation(field: BuiltinField, value: unknown): EntryRule | null { + if (isBlank(value)) { + return field.required ? 'required' : null + } + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 'type' + } + if (typeof field.min === 'number' && value < field.min) { + return 'min' + } + return typeof field.max === 'number' && value > field.max ? 'max' : null +} + +function dayBeyond(value: string, limit: Limit, side: 'min' | 'max') { + if (typeof limit !== 'string' || limit === '') { + return false + } + return side === 'min' ? value < limit : value > limit +} + +function dateViolation(field: BuiltinField, value: unknown): EntryRule | null { + if (isBlank(value)) { + return field.required ? 'required' : null + } + if (!isIsoDay(value)) { + return 'type' + } + if (dayBeyond(value, field.min, 'min')) { + return 'min' + } + return dayBeyond(value, field.max, 'max') ? 'max' : null +} + +function choiceViolation(field: BuiltinField, value: unknown): EntryRule | null { + if (isBlank(value)) { + return field.required ? 'required' : null + } + return (field.options ?? []).map(String).includes(String(value)) ? null : 'options' +} + +function booleanViolation(_field: BuiltinField, value: unknown): EntryRule | null { + return isBlank(value) || typeof value === 'boolean' ? null : 'type' +} + +function booleanInitial(field: BuiltinField, stored: unknown, creating: boolean) { + return asBoolean(creating ? field.default : stored) +} + +function booleanPayload(field: BuiltinField, value: unknown) { + if (typeof value === 'boolean') { + return value + } + return field.required ? false : null +} + +function noHelp() { + return '' +} + +function noCounter() { + return null +} + +function emptyAsText(value: unknown) { + return isBlank(value) ? '' : String(value) +} + +export const ENTRY_EDITORS: Record<FieldType, EntryEditorSpec> = { + text: { + control: 'input', + initial: textInitial, + validate: lengthViolation, + payload: textPayload, + help: textHelp, + counter: lengthCounter, + }, + richtext: { + control: 'markdown', + initial: textInitial, + validate: lengthViolation, + payload: textPayload, + help: () => t('Markdown. Switch to Preview to check the result.'), + counter: lengthCounter, + }, + number: { + control: 'number', + initial: (field, stored, creating) => asNumber(creating ? field.default : stored), + validate: numberViolation, + payload: (_field, value) => (isBlank(value) ? null : value), + help: rangeHelp, + counter: noCounter, + }, + boolean: { + control: 'switch', + initial: booleanInitial, + validate: booleanViolation, + payload: booleanPayload, + help: noHelp, + counter: noCounter, + }, + date: { + control: 'date', + initial: (field, stored, creating) => asText(creating ? field.default : stored), + validate: dateViolation, + payload: (_field, value) => (isBlank(value) ? null : emptyAsText(value)), + help: rangeHelp, + counter: noCounter, + }, + select: { + control: 'select', + initial: (field, stored, creating) => asText(creating ? field.default : stored), + validate: choiceViolation, + payload: textPayload, + help: noHelp, + counter: noCounter, + }, +} diff --git a/web/src/lib/entryForm.ts b/web/src/lib/entryForm.ts new file mode 100644 index 0000000..5fda7ff --- /dev/null +++ b/web/src/lib/entryForm.ts @@ -0,0 +1,77 @@ +import { t } from '@/i18n' +import type { BuiltinField, ContentEntry } from '@/lib/contentTypes' +import { describeCell, plainCellText } from '@/lib/entryCells' +import { ruleMessage, type EntryRule } from '@/lib/entryMessages' +import { isBlank } from '@/lib/entryValue' +import { fieldTypeSpec } from '@/lib/fieldTypes' + +export type EntryValues = Record<string, unknown> + +export interface FieldIssue { + rule: EntryRule + message: string +} + +export type EntryIssues = Record<string, FieldIssue> + +export function initialValues(fields: BuiltinField[], stored: ContentEntry | null): EntryValues { + return Object.fromEntries( + fields.map((field) => [ + field.key, + fieldTypeSpec(field.type).editor.initial( + field, + stored?.data[field.key], + stored === null, + ), + ]), + ) +} + +export function buildData(fields: BuiltinField[], values: EntryValues) { + return Object.fromEntries( + fields.map((field) => [ + field.key, + fieldTypeSpec(field.type).editor.payload(field, values[field.key]), + ]), + ) +} + +export function issueFor(field: BuiltinField, rule: EntryRule): FieldIssue { + return { rule, message: ruleMessage(rule, field) } +} + +export function validateEntry(fields: BuiltinField[], values: EntryValues): EntryIssues { + const issues: EntryIssues = {} + for (const field of fields) { + const rule = fieldTypeSpec(field.type).editor.validate(field, values[field.key]) + if (rule) { + issues[field.key] = issueFor(field, rule) + } + } + return issues +} + +export function firstIssueKey(fields: BuiltinField[], issues: EntryIssues) { + return fields.find((field) => issues[field.key])?.key ?? null +} + +export function valuesDiffer(fields: BuiltinField[], current: EntryValues, saved: EntryValues) { + return fields.some((field) => !Object.is(current[field.key], saved[field.key])) +} + +export function carriesDefault(field: BuiltinField, value: unknown) { + if (isBlank(field.default)) { + return false + } + const initial = fieldTypeSpec(field.type).editor.initial(field, undefined, true) + return Object.is(initial, value) +} + +export function entryTitle(fields: BuiltinField[], entry: ContentEntry | null) { + const first = fields[0] + if (!first || !entry) { + return entry ? t('Entry {0}', [entry.id]) : t('New entry') + } + const cell = describeCell(first, entry.data[first.key]) + return cell.kind === 'empty' ? t('Entry {0}', [entry.id]) : plainCellText(cell) +} diff --git a/web/src/lib/entryLayout.ts b/web/src/lib/entryLayout.ts new file mode 100644 index 0000000..edc4029 --- /dev/null +++ b/web/src/lib/entryLayout.ts @@ -0,0 +1,15 @@ +import type { BuiltinField } from '@/lib/contentTypes' +import { fieldTypeSpec } from '@/lib/fieldTypes' + +export function groupFields(fields: BuiltinField[]) { + const rows: BuiltinField[][] = [] + let index = 0 + while (index < fields.length) { + const current = fields[index]! + const next = fields[index + 1] + const paired = next && fieldTypeSpec(current.type).pairs && fieldTypeSpec(next.type).pairs + rows.push(paired ? [current, next] : [current]) + index += paired ? 2 : 1 + } + return rows +} diff --git a/web/src/lib/entryMessages.ts b/web/src/lib/entryMessages.ts new file mode 100644 index 0000000..ae41a8d --- /dev/null +++ b/web/src/lib/entryMessages.ts @@ -0,0 +1,155 @@ +import { t } from '@/i18n' +import type { BuiltinField } from '@/lib/contentTypes' +import { formatDayValue } from '@/lib/entryValue' + +export type EntryRule = 'required' | 'type' | 'min' | 'max' | 'options' | 'unique' + +function bound(field: BuiltinField, side: 'min' | 'max') { + return field[side] ?? '' +} + +function choices(field: BuiltinField) { + const options = (field.options ?? []).map(String) + if (options.length < 2) { + return options.join('') + } + return t('{0} or {1}', [options.slice(0, -1).join(', '), options[options.length - 1]!]) +} + +function requiredMessage(field: BuiltinField) { + switch (field.type) { + case 'date': + return t('Pick a date for {0}.', [field.label]) + case 'select': + return t('Choose an option for {0}.', [field.label]) + case 'boolean': + return t('Choose yes or no for {0}.', [field.label]) + default: + return t('Fill in {0}.', [field.label]) + } +} + +function typeMessage(field: BuiltinField) { + switch (field.type) { + case 'number': + return t('{0} must be a number.', [field.label]) + case 'boolean': + return t('{0} must be yes or no.', [field.label]) + case 'date': + return t('{0} must be a date, like 2026-11-21.', [field.label]) + case 'select': + return t('{0} must be one of the choices.', [field.label]) + default: + return t('{0} must be text.', [field.label]) + } +} + +function minMessage(field: BuiltinField) { + const limit = bound(field, 'min') + if (field.type === 'number') { + return t('{0} must be at least {1}.', [field.label, String(limit)]) + } + if (field.type === 'date') { + return t("{0} can't be before {1}.", [field.label, formatDayValue(String(limit))]) + } + return t('{0} needs at least {1} characters.', [field.label, String(limit)]) +} + +function maxMessage(field: BuiltinField) { + const limit = bound(field, 'max') + if (field.type === 'number') { + return t('{0} must be at most {1}.', [field.label, String(limit)]) + } + if (field.type === 'date') { + return t("{0} can't be after {1}.", [field.label, formatDayValue(String(limit))]) + } + return t('{0} can be up to {1} characters.', [field.label, String(limit)]) +} + +function uniqueMessage(field: BuiltinField) { + switch (field.type) { + case 'select': + return t('Another entry already uses this choice for {0}.', [field.label]) + case 'date': + return t('Another entry already uses this date for {0}.', [field.label]) + default: + return t('Another entry already uses this {0}. Enter a different one.', [field.label]) + } +} + +export function ruleMessage(rule: EntryRule, field: BuiltinField) { + switch (rule) { + case 'required': + return requiredMessage(field) + case 'type': + return typeMessage(field) + case 'min': + return minMessage(field) + case 'max': + return maxMessage(field) + case 'options': + return t('Choose one of: {0}.', [choices(field)]) + default: + return uniqueMessage(field) + } +} + +export function uniqueAlert(field: BuiltinField) { + switch (field.type) { + case 'select': + return t('Another entry already uses this choice. Change it and save again.') + case 'date': + return t('Another entry already uses this date. Change it and save again.') + default: + return t('Another entry already uses this {0}. Change it and save again.', [ + field.label.toLowerCase(), + ]) + } +} + +export function uniqueHelp(field: BuiltinField) { + return t('Each entry needs a different {0}.', [field.label.toLowerCase()]) +} + +export function rangeHelp(field: BuiltinField) { + const min = field.min + const max = field.max + if (field.type === 'date') { + const from = min === null || min === undefined ? '' : formatDayValue(String(min)) + const until = max === null || max === undefined ? '' : formatDayValue(String(max)) + if (from && until) { + return t('From {0} until {1}.', [from, until]) + } + if (from) { + return t('From {0}.', [from]) + } + return until ? t('Until {0}.', [until]) : '' + } + if (typeof min === 'number' && typeof max === 'number') { + return t('Between {0} and {1}.', [String(min), String(max)]) + } + if (typeof min === 'number') { + return t('At least {0}.', [String(min)]) + } + return typeof max === 'number' ? t('At most {0}.', [String(max)]) : '' +} + +export function schemaChangedLine(key: string) { + const name = key.replace(/_/g, ' ') + return t( + "A field was removed and a new required field, {0}, was added after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.", + [name.charAt(0).toUpperCase() + name.slice(1)], + ) +} + +export function entryLoadErrorLine(path: string) { + return t('GET {0} failed. Check your connection and try again.', [path]) +} + +export function typeLoadErrorLine(key: string) { + return t('GET /v1/content-type/{0} failed. Check your connection and try again.', [key]) +} + +export function entriesLoadErrorLine(key: string) { + return t('GET /v1/content-type/{0}/entry failed. Check your connection and try again.', [key]) +} diff --git a/web/src/lib/entryServerError.ts b/web/src/lib/entryServerError.ts new file mode 100644 index 0000000..ead0361 --- /dev/null +++ b/web/src/lib/entryServerError.ts @@ -0,0 +1,50 @@ +import { ApiError } from '@/lib/apiError' +import type { BuiltinField } from '@/lib/contentTypes' +import type { EntryRule } from '@/lib/entryMessages' + +const REFUSAL = /^invalid_entry:\s*([^:\s]+)\s*:\s*([a-z_]+)\s*$/ +const FIELD_RULES: readonly string[] = ['required', 'type', 'min', 'max', 'options', 'unique'] +const REFUSAL_STATUSES = [409, 422] +const GONE_STATUSES = [403, 404] + +export interface EntryRefusal { + key: string + rule: string + status: number +} + +export type RefusalOutcome = + | { kind: 'field'; field: BuiltinField; rule: EntryRule; status: number } + | { kind: 'schema'; key: string } + | { kind: 'unknown' } + +export function isGoneError(error: unknown) { + return error instanceof ApiError && GONE_STATUSES.includes(error.status) +} + +export function parseEntryRefusal(error: unknown): EntryRefusal | null { + if (!(error instanceof ApiError) || !REFUSAL_STATUSES.includes(error.status)) { + return null + } + const match = REFUSAL.exec(error.body ?? '') + return match ? { key: match[1]!, rule: match[2]!, status: error.status } : null +} + +export function classifyRefusal( + fields: BuiltinField[], + refusal: EntryRefusal | null, +): RefusalOutcome { + if (!refusal) { + return { kind: 'unknown' } + } + const field = fields.find((candidate) => candidate.key === refusal.key) + if (!field || !FIELD_RULES.includes(refusal.rule)) { + return { kind: 'schema', key: refusal.key } + } + return { + kind: 'field', + field, + rule: refusal.rule as EntryRule, + status: refusal.status, + } +} diff --git a/web/src/lib/entryStamp.ts b/web/src/lib/entryStamp.ts new file mode 100644 index 0000000..0a104de --- /dev/null +++ b/web/src/lib/entryStamp.ts @@ -0,0 +1,10 @@ +import { formatAbsoluteDateFromIso } from '@/lib/relativeDate' +import { formatClock } from '@/lib/updateFormat' + +export function formatEntryStamp(iso: string) { + return `${formatAbsoluteDateFromIso(iso)}, ${formatClock(new Date(iso))}` +} + +export function formatSavedClock(iso: string) { + return formatClock(new Date(iso)) +} diff --git a/web/src/lib/entryValue.ts b/web/src/lib/entryValue.ts new file mode 100644 index 0000000..9ca5aa5 --- /dev/null +++ b/web/src/lib/entryValue.ts @@ -0,0 +1,59 @@ +import { formatAbsoluteDate } from '@/lib/relativeDate' + +const ISO_DAY_PREFIX = /^(\d{4})-(\d{2})-(\d{2})/ +const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})$/ + +export function isBlank(value: unknown) { + return value === null || value === undefined || value === '' +} + +export function charCount(text: string) { + return Array.from(text).length +} + +export function isIsoDay(value: unknown): value is string { + if (typeof value !== 'string') { + return false + } + const match = ISO_DAY.exec(value) + if (!match) { + return false + } + const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])] + const date = new Date(year, month - 1, day) + return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day +} + +export function asText(value: unknown) { + return value === null || value === undefined ? '' : String(value) +} + +export function asNumber(value: unknown) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) { + return Number(value) + } + return null +} + +export function asBoolean(value: unknown) { + return typeof value === 'boolean' ? value : null +} + +export function formatDayValue(value: string) { + const match = ISO_DAY_PREFIX.exec(value) + if (!match) { + return value + } + return formatAbsoluteDate(new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))) +} + +export function freeSentinel(taken: string[], base = '__none__') { + let sentinel = base + while (taken.includes(sentinel)) { + sentinel += '_' + } + return sentinel +} diff --git a/web/src/lib/fieldTypes.ts b/web/src/lib/fieldTypes.ts new file mode 100644 index 0000000..c205a2f --- /dev/null +++ b/web/src/lib/fieldTypes.ts @@ -0,0 +1,106 @@ +import { t } from '@/i18n' +import { ENTRY_EDITORS, type EntryEditorSpec } from '@/lib/entryEditors' + +export const FIELD_TYPES = ['text', 'richtext', 'number', 'boolean', 'date', 'select'] as const + +export type FieldType = (typeof FIELD_TYPES)[number] + +export type FieldCellKind = 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'choice' + +export interface FieldTypeSpec { + icon: string + label: () => string + description: () => string + cell: FieldCellKind + narrowColumn: boolean + alignEnd: boolean + supportsUnique: boolean + supportsLimits: boolean + pairs: boolean + editor: EntryEditorSpec +} + +const FALLBACK_TYPE: FieldType = 'text' + +export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = { + text: { + icon: 'i-lucide-type', + label: () => t('Text'), + description: () => t('Short text, like a title or a name'), + cell: 'text', + narrowColumn: false, + alignEnd: false, + supportsUnique: true, + supportsLimits: true, + pairs: false, + editor: ENTRY_EDITORS.text, + }, + richtext: { + icon: 'i-lucide-pilcrow', + label: () => t('Rich text'), + description: () => t('Long text with formatting, written in Markdown'), + cell: 'richtext', + narrowColumn: false, + alignEnd: false, + supportsUnique: false, + supportsLimits: true, + pairs: false, + editor: ENTRY_EDITORS.richtext, + }, + number: { + icon: 'i-lucide-hash', + label: () => t('Number'), + description: () => t('Whole or decimal numbers'), + cell: 'number', + narrowColumn: true, + alignEnd: true, + supportsUnique: true, + supportsLimits: true, + pairs: true, + editor: ENTRY_EDITORS.number, + }, + boolean: { + icon: 'i-lucide-toggle-left', + label: () => t('Yes or no'), + description: () => t('An on or off switch'), + cell: 'boolean', + narrowColumn: true, + alignEnd: false, + supportsUnique: false, + supportsLimits: false, + pairs: true, + editor: ENTRY_EDITORS.boolean, + }, + date: { + icon: 'i-lucide-calendar', + label: () => t('Date'), + description: () => t('A calendar day, without a time'), + cell: 'date', + narrowColumn: false, + alignEnd: false, + supportsUnique: true, + supportsLimits: true, + pairs: true, + editor: ENTRY_EDITORS.date, + }, + select: { + icon: 'i-lucide-list', + label: () => t('Choice'), + description: () => t('One option from a list you define'), + cell: 'choice', + narrowColumn: false, + alignEnd: false, + supportsUnique: true, + supportsLimits: false, + pairs: false, + editor: ENTRY_EDITORS.select, + }, +} + +export function isFieldType(type: string): type is FieldType { + return (FIELD_TYPES as readonly string[]).includes(type) +} + +export function fieldTypeSpec(type: string): FieldTypeSpec { + return FIELD_TYPE_SPECS[isFieldType(type) ? type : FALLBACK_TYPE] +} diff --git a/web/src/lib/markdown.ts b/web/src/lib/markdown.ts new file mode 100644 index 0000000..d9a640b --- /dev/null +++ b/web/src/lib/markdown.ts @@ -0,0 +1,22 @@ +import MarkdownIt from 'markdown-it' + +const SAFE_LINK = /^(https?:|mailto:)/i + +const renderer = new MarkdownIt({ html: false, linkify: false, breaks: false }) +renderer.disable('image') +renderer.validateLink = (url) => SAFE_LINK.test(url.trim()) + +const defaultLinkOpen = + renderer.renderer.rules.link_open ?? + ((tokens, index, options, _env, self) => self.renderToken(tokens, index, options)) + +renderer.renderer.rules.link_open = (tokens, index, options, env, self) => { + const token = tokens[index]! + token.attrSet('target', '_blank') + token.attrSet('rel', 'noopener noreferrer') + return defaultLinkOpen(tokens, index, options, env, self) +} + +export function renderMarkdown(source: string) { + return renderer.render(source) +} diff --git a/web/src/lib/markdownEdit.ts b/web/src/lib/markdownEdit.ts new file mode 100644 index 0000000..3001439 --- /dev/null +++ b/web/src/lib/markdownEdit.ts @@ -0,0 +1,66 @@ +export type MarkdownAction = 'bold' | 'italic' | 'heading' | 'link' | 'list' + +export const MARKDOWN_ACTIONS: MarkdownAction[] = ['bold', 'italic', 'heading', 'link', 'list'] + +export interface MarkdownEdit { + value: string + start: number + end: number +} + +const LINK_PLACEHOLDER = 'url' + +function wrap(value: string, start: number, end: number, mark: string): MarkdownEdit { + const inner = value.slice(start, end) + return { + value: `${value.slice(0, start)}${mark}${inner}${mark}${value.slice(end)}`, + start: start + mark.length, + end: end + mark.length, + } +} + +function lineStart(value: string, index: number) { + return index === 0 ? 0 : value.lastIndexOf('\n', index - 1) + 1 +} + +function prefixLines(value: string, start: number, end: number, prefix: string): MarkdownEdit { + const from = lineStart(value, start) + const block = value.slice(from, end) + const lines = block.split('\n') + const prefixed = lines.map((line) => `${prefix}${line}`).join('\n') + return { + value: `${value.slice(0, from)}${prefixed}${value.slice(end)}`, + start: start + prefix.length, + end: end + prefix.length * lines.length, + } +} + +function link(value: string, start: number, end: number): MarkdownEdit { + const inner = value.slice(start, end) + const urlStart = start + inner.length + 3 + return { + value: `${value.slice(0, start)}[${inner}](${LINK_PLACEHOLDER})${value.slice(end)}`, + start: urlStart, + end: urlStart + LINK_PLACEHOLDER.length, + } +} + +export function applyMarkdownAction( + action: MarkdownAction, + value: string, + start: number, + end: number, +): MarkdownEdit { + switch (action) { + case 'bold': + return wrap(value, start, end, '**') + case 'italic': + return wrap(value, start, end, '_') + case 'heading': + return prefixLines(value, start, start, '## ') + case 'link': + return link(value, start, end) + default: + return prefixLines(value, start, end, '- ') + } +} diff --git a/web/src/lib/schemaBuilderDiff.ts b/web/src/lib/schemaBuilderDiff.ts index 80401a9..c41ea09 100644 --- a/web/src/lib/schemaBuilderDiff.ts +++ b/web/src/lib/schemaBuilderDiff.ts @@ -1,5 +1,5 @@ import { t } from '@/i18n' -import { fieldTypeLabel } from '@/lib/schemaFields' +import { fieldTypeSpec } from '@/lib/fieldTypes' import type { BuilderField } from '@/lib/schemaBuilderModel' import { payloadField, type PayloadField } from '@/lib/schemaBuilderPayload' @@ -151,7 +151,10 @@ function changeNotes(before: PayloadField, after: PayloadField) { } function addNote(field: PayloadField) { - const parts = [fieldTypeLabel(field.type), field.required ? t('required') : t('optional')] + const parts = [ + fieldTypeSpec(field.type).label(), + field.required ? t('required') : t('optional'), + ] if (field.default !== null) { parts.push( field.type === 'boolean' diff --git a/web/src/lib/schemaBuilderLoad.ts b/web/src/lib/schemaBuilderLoad.ts index 9ab96c3..fb1720a 100644 --- a/web/src/lib/schemaBuilderLoad.ts +++ b/web/src/lib/schemaBuilderLoad.ts @@ -1,4 +1,4 @@ -import type { BuiltinField, ContentType } from '@/composables/useContentTypes' +import type { BuiltinField, ContentType } from '@/lib/contentTypes' import { FIELD_TYPES, createField, diff --git a/web/src/lib/schemaBuilderModel.ts b/web/src/lib/schemaBuilderModel.ts index d1be4b8..7595eae 100644 --- a/web/src/lib/schemaBuilderModel.ts +++ b/web/src/lib/schemaBuilderModel.ts @@ -1,6 +1,6 @@ -export const FIELD_TYPES = ['text', 'richtext', 'number', 'boolean', 'date', 'select'] as const +import { FIELD_TYPE_SPECS, FIELD_TYPES, type FieldType } from '@/lib/fieldTypes' -export type FieldType = (typeof FIELD_TYPES)[number] +export { FIELD_TYPES, type FieldType } export type KeyMode = 'auto' | 'manual' export type KeyDisplayMode = KeyMode | 'empty' export type BuilderStatus = @@ -104,11 +104,11 @@ export function changeFieldType(field: BuilderField, type: FieldType): BuilderFi } export function supportsUnique(type: FieldType) { - return type !== 'richtext' && type !== 'boolean' + return FIELD_TYPE_SPECS[type].supportsUnique } export function supportsLimits(type: FieldType) { - return type === 'text' || type === 'richtext' || type === 'number' || type === 'date' + return FIELD_TYPE_SPECS[type].supportsLimits } export function hasDefaultValue(field: BuilderField) { diff --git a/web/src/lib/schemaFields.ts b/web/src/lib/schemaFields.ts index 2c6a69c..2c7cc9a 100644 --- a/web/src/lib/schemaFields.ts +++ b/web/src/lib/schemaFields.ts @@ -1,38 +1,9 @@ import { t, formatNumber } from '@/i18n' -import type { BuiltinField } from '@/composables/useContentTypes' +import type { BuiltinField } from '@/lib/contentTypes' +import { asNumber } from '@/lib/entryValue' const RULE_SEPARATOR = ' · ' -const FIELD_ICONS: Record<string, string> = { - text: 'i-lucide-type', - richtext: 'i-lucide-pilcrow', - number: 'i-lucide-hash', - boolean: 'i-lucide-toggle-left', - date: 'i-lucide-calendar', - select: 'i-lucide-list', -} - -export function fieldIcon(type: string) { - return FIELD_ICONS[type] ?? FIELD_ICONS.text! -} - -export function fieldTypeLabel(type: string) { - switch (type) { - case 'richtext': - return t('Rich text') - case 'number': - return t('Number') - case 'boolean': - return t('Yes or no') - case 'date': - return t('Date') - case 'select': - return t('Choice') - default: - return t('Text') - } -} - function present<T>(value: T | null | undefined): value is T { return value !== null && value !== undefined } @@ -78,10 +49,10 @@ function defaultRule(field: BuiltinField) { function constraintRule(field: BuiltinField) { if (field.type === 'text' || field.type === 'richtext') { - return lengthRule(field.min, field.max) + return lengthRule(asNumber(field.min), asNumber(field.max)) } if (field.type === 'number') { - return rangeRule(field.min, field.max) + return rangeRule(asNumber(field.min), asNumber(field.max)) } if (field.type === 'select' && Array.isArray(field.options) && field.options.length > 0) { return t('{0} choices', [field.options.length]) diff --git a/web/src/lib/schemaRows.ts b/web/src/lib/schemaRows.ts index 3b16194..3d4d29e 100644 --- a/web/src/lib/schemaRows.ts +++ b/web/src/lib/schemaRows.ts @@ -1,5 +1,5 @@ import { t, formatNumber } from '@/i18n' -import type { BuiltinType, ContentType } from '@/composables/useContentTypes' +import type { BuiltinType, ContentType } from '@/lib/contentTypes' import { formatAbsoluteDateFromIso } from '@/lib/relativeDate' export const SCHEMA_TABLE_QUERY = '(min-width: 40rem)' diff --git a/web/src/router/index.ts b/web/src/router/index.ts index b94975c..748f804 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -107,6 +107,18 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/ContentListView.vue'), meta: { auth: 'verified', title: 'Content' }, }, + { + path: '/content/:key/new', + name: 'entry-new', + component: () => import('@/views/EntryEditorView.vue'), + meta: { auth: 'verified', title: 'Content' }, + }, + { + path: '/content/:key/:id', + name: 'entry-edit', + component: () => import('@/views/EntryEditorView.vue'), + meta: { auth: 'verified', title: 'Content' }, + }, { path: '/activity', component: () => import('@/views/ActivityView.vue'), diff --git a/web/src/views/ContentListPage.vue b/web/src/views/ContentListPage.vue new file mode 100644 index 0000000..7e663b2 --- /dev/null +++ b/web/src/views/ContentListPage.vue @@ -0,0 +1,61 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +import { computed, watch } from 'vue' +import ContentEntries from '@/components/ContentEntries.vue' +import DashboardShell from '@/components/DashboardShell.vue' +import { useContentListPage } from '@/composables/useContentListPage' +import { useContentTypeDetail } from '@/composables/useContentTypeDetail' +import { useSessionStore } from '@/stores/session' + +const props = defineProps<{ typeKey: string }>() + +const session = useSessionStore() + +const detail = useContentTypeDetail( + () => props.typeKey, + () => session.status === 'ready', +) +const page = useContentListPage(props.typeKey, detail, () => session.canManageContent) + +const notFound = computed(() => page.view.value === 'notFound') +const centered = computed(() => page.centered.value) +const displayName = computed(() => + notFound.value ? undefined : (detail.type.value?.name ?? props.typeKey), +) +const mobileTitle = computed(() => displayName.value ?? t('Content')) +const breadcrumb = computed(() => [ + { label: t('Content') }, + ...(displayName.value ? [{ label: displayName.value }] : []), +]) + +watch( + () => session.status === 'ready', + (ready, wasReady) => { + if (ready && !wasReady) { + page.list.start() + } + }, + { immediate: true }, +) +</script> + +<template> + <DashboardShell + compact-padding + :full-bleed="centered" + :mobile-title="mobileTitle" + :no-current-nav="notFound" + > + <template #breadcrumb> + <UBreadcrumb + :items="breadcrumb" + color="neutral" + class="hidden lg:block" + :ui="{ link: 'text-sm font-medium' }" + /> + </template> + + <ContentEntries v-if="session.status === 'ready'" :page="page" /> + </DashboardShell> +</template> diff --git a/web/src/views/ContentListView.vue b/web/src/views/ContentListView.vue index 4794658..8ba1c2c 100644 --- a/web/src/views/ContentListView.vue +++ b/web/src/views/ContentListView.vue @@ -1,64 +1,12 @@ <script setup lang="ts"> -import { t } from '@/i18n' - -import { computed, ref, watch } from 'vue' +import { computed } from 'vue' import { useRoute } from 'vue-router' -import ContentEntries from '@/components/ContentEntries.vue' -import DashboardShell from '@/components/DashboardShell.vue' -import { useContentTypeDetail } from '@/composables/useContentTypeDetail' -import type { ContentListState } from '@/lib/contentListState' -import { useSessionStore } from '@/stores/session' +import ContentListPage from '@/views/ContentListPage.vue' const route = useRoute() -const session = useSessionStore() - const key = computed(() => String(route.params.key)) -const detail = useContentTypeDetail(() => key.value) -const listView = ref<ContentListState>('loading') - -watch(key, () => { - listView.value = 'loading' -}) - -const notFound = computed(() => detail.status.value === 'missing' || listView.value === 'notFound') -const centered = computed( - () => notFound.value || detail.status.value === 'forbidden' || listView.value === 'forbidden', -) -const displayName = computed(() => - notFound.value ? undefined : (detail.type.value?.name ?? key.value), -) -const mobileTitle = computed(() => displayName.value ?? t('Content')) -const breadcrumb = computed(() => [ - { label: t('Content') }, - ...(displayName.value ? [{ label: displayName.value }] : []), -]) </script> <template> - <DashboardShell - compact-padding - :full-bleed="centered" - :mobile-title="mobileTitle" - :no-current-nav="notFound" - > - <template #breadcrumb> - <UBreadcrumb - :items="breadcrumb" - color="neutral" - class="hidden lg:block" - :ui="{ link: 'text-sm font-medium' }" - /> - </template> - - <ContentEntries - v-if="session.status === 'ready'" - :key="key" - :type-key="key" - :type="detail.type.value" - :type-status="detail.status.value" - :can-edit="session.canManageContent" - @view="listView = $event" - @retry-type="detail.retry" - /> - </DashboardShell> + <ContentListPage :key="key" :type-key="key" /> </template> diff --git a/web/src/views/EntryEditorPage.vue b/web/src/views/EntryEditorPage.vue new file mode 100644 index 0000000..e1aa4cb --- /dev/null +++ b/web/src/views/EntryEditorPage.vue @@ -0,0 +1,80 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +import { computed } from 'vue' +import DashboardShell from '@/components/DashboardShell.vue' +import EntryEditor from '@/components/EntryEditor.vue' +import { useEntryEditorPage } from '@/composables/useEntryEditorPage' +import type { EntryTarget } from '@/lib/entryEditorState' +import { contentPath } from '@/lib/routePaths' + +const props = defineProps<{ + typeKey: string + target: EntryTarget + adopt: (id: number | null) => void +}>() + +const page = useEntryEditorPage(props.typeKey, props.target, props.adopt) + +const listPath = computed(() => contentPath(props.typeKey)) +const typeMissing = computed(() => page.phase.value === 'typeNotFound') +const centered = computed(() => + ['typeNotFound', 'entryNotFound', 'forbidden'].includes(page.phase.value), +) +const showsTitle = computed(() => page.phase.value === 'ready' && !centered.value) +const mobileTitle = computed(() => { + if (typeMissing.value || page.phase.value === 'forbidden') { + return t('Content') + } + return showsTitle.value ? page.title.value : page.typeName.value +}) +const breadcrumb = computed(() => { + if (typeMissing.value) { + return [{ label: t('Content') }] + } + return [ + { label: t('Content') }, + { label: page.typeName.value, to: listPath.value }, + ...(showsTitle.value ? [{ label: page.title.value }] : []), + ] +}) +const showDelete = computed(() => !page.isDesktop.value && page.ready.value && !page.creating.value) +</script> + +<template> + <DashboardShell + compact-padding + sticky-footer + :full-bleed="centered" + :mobile-title="mobileTitle" + :back-to="listPath" + :back-label="page.typeName.value" + :no-current-nav="typeMissing" + > + <template #breadcrumb> + <UBreadcrumb + :items="breadcrumb" + color="neutral" + class="hidden lg:block" + :ui="{ link: 'text-sm font-medium' }" + /> + </template> + + <template #navbar-right> + <UButton + v-if="showDelete" + type="button" + color="neutral" + variant="ghost" + icon="i-lucide-trash-2" + :disabled="page.saving.value" + :aria-label="t('Delete')" + class="size-11 justify-center text-muted" + :ui="{ leadingIcon: 'size-5' }" + @click="page.removal.open" + /> + </template> + + <EntryEditor :page="page" /> + </DashboardShell> +</template> diff --git a/web/src/views/EntryEditorView.vue b/web/src/views/EntryEditorView.vue new file mode 100644 index 0000000..b569cdd --- /dev/null +++ b/web/src/views/EntryEditorView.vue @@ -0,0 +1,41 @@ +<script setup lang="ts"> +import { computed, ref, watch } from 'vue' +import { useRoute } from 'vue-router' +import DashboardShell from '@/components/DashboardShell.vue' +import { resolveEntryTarget } from '@/lib/entryEditorState' +import EntryEditorPage from '@/views/EntryEditorPage.vue' +import { useSessionStore } from '@/stores/session' + +const route = useRoute() +const session = useSessionStore() + +const typeKey = computed(() => String(route.params.key)) +const rawId = computed(() => (route.name === 'entry-new' ? 'new' : String(route.params.id))) +const target = computed(() => resolveEntryTarget(rawId.value)) +const identity = computed(() => `${typeKey.value}/${rawId.value}`) + +const instance = ref(0) +let adopted: string | null = null + +function adopt(id: number | null) { + adopted = id === null ? null : `${typeKey.value}/${id}` +} + +watch(identity, (next) => { + if (next !== adopted) { + instance.value++ + } + adopted = null +}) +</script> + +<template> + <EntryEditorPage + v-if="session.status === 'ready'" + :key="instance" + :type-key="typeKey" + :target="target" + :adopt="adopt" + /> + <DashboardShell v-else compact-padding /> +</template> diff --git a/web/src/views/__tests__/EntryEditorControls.spec.ts b/web/src/views/__tests__/EntryEditorControls.spec.ts new file mode 100644 index 0000000..918db1d --- /dev/null +++ b/web/src/views/__tests__/EntryEditorControls.spec.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { flushPromises } from '@vue/test-utils' +import { + MOBILE_WIDTH, + chip, + control, + entryWith, + fieldWrapper, + installSelectPolyfills, + mountEditor, + openChoices, + text, + typeInto, + unmountAll, +} from './entryEditorKit' + +vi.mock('@/composables/useApi', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/composables/useApi')>() + return { ...actual, useApi: vi.fn() } +}) + +beforeAll(installSelectPolyfills) + +afterEach(unmountAll) + +function preview() { + return document.body.querySelector('[data-testid="markdown-preview"]') +} + +function tab(label: string) { + return [...document.body.querySelectorAll<HTMLElement>('[role="tab"]')].find( + (candidate) => text(candidate) === label, + )! +} + +function toolbarButton(label: string) { + return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)! +} + +async function pressTab(label: string) { + const target = tab(label) + target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) + target.focus() + target.click() + await flushPromises() +} + +describe('Form controls', () => { + it('uses one control per field type', async () => { + await mountEditor('/content/events/42') + + expect(fieldWrapper('title').querySelector('input[type="text"]')).toBeTruthy() + expect(fieldWrapper('description').querySelector('textarea')).toBeTruthy() + expect(fieldWrapper('capacity').querySelector('input')).toBeTruthy() + expect(fieldWrapper('online').querySelector('[role="switch"]')).toBeTruthy() + expect(fieldWrapper('starts_on').querySelector('[role="spinbutton"]')).toBeTruthy() + expect(fieldWrapper('category').querySelector('[role="combobox"]')).toBeTruthy() + }) + + it('toggles the switch text between Off and On and marks the change', async () => { + await mountEditor('/content/events/42') + expect(text(fieldWrapper('online'))).toContain('Off') + + fieldWrapper('online').querySelector<HTMLElement>('[role="switch"]')!.click() + await flushPromises() + + expect(text(fieldWrapper('online'))).toContain('On') + expect(text(chip())).toBe('Unsaved changes') + }) + + it('lists the choices in schema order with a None item for an optional select', async () => { + await mountEditor('/content/events/42') + + const options = await openChoices('category') + + expect(options.map((option) => text(option))).toEqual([ + 'None', + 'Talk', + 'Workshop', + 'Meetup', + ]) + }) + + it('clears an optional select with None', async () => { + await mountEditor('/content/events/42') + const options = await openChoices('category') + + options[0]!.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })) + options[0]!.click() + await flushPromises() + + expect(text(fieldWrapper('category'))).toContain('Choose an option') + expect(text(chip())).toBe('Unsaved changes') + }) + + it('shows a counter while typing', async () => { + await mountEditor('/content/events/42') + + await typeInto('title', 'Lime plaster workshop, two days') + + expect(text(fieldWrapper('title'))).toContain('31 / 80') + expect( + fieldWrapper('title').querySelector('[data-testid="entry-counter"]')!.className, + ).not.toContain('text-error') + }) + + it('has no counter for a text field without a max', async () => { + await mountEditor('/content/events/42', { + entry: () => entryWith({}), + type: () => ({ + id: 1, + key: 'events', + name: 'Events', + icon: null, + created_at: '2026-09-01T09:30:00Z', + updated_at: '2026-09-01T09:30:00Z', + fields: [ + { + key: 'title', + label: 'Title', + type: 'text', + required: false, + unique: false, + default: null, + min: null, + max: null, + options: [], + }, + ], + }), + }) + + expect(fieldWrapper('title').querySelector('[data-testid="entry-counter"]')).toBeNull() + }) + + it('puts every field in one column below md', async () => { + await mountEditor('/content/events/42', { width: MOBILE_WIDTH }) + + const row = fieldWrapper('starts_on').parentElement! + expect(row.className).toContain('md:grid-cols-2') + expect(row.className).not.toMatch(/(^|\s)grid-cols-2/) + }) +}) + +describe('4 Markdown preview', () => { + it('renders bold text and a list in the Preview tab and keeps the value', async () => { + await mountEditor('/content/events/42') + const value = control('description').value + + await pressTab('Preview') + + expect(preview()!.innerHTML).toContain('<strong>lime putty</strong>') + expect(preview()!.querySelectorAll('li')).toHaveLength(2) + expect(toolbarButton('Bold')).toBeNull() + expect(text(chip())).toBe('Saved') + + await pressTab('Write') + + expect(control('description').value).toBe(value) + expect(preview()).toBeNull() + expect(toolbarButton('Bold')).toBeTruthy() + }) + + it('shows raw HTML as text and keeps unsafe links inert', async () => { + await mountEditor('/content/events/42', { + entry: () => + entryWith({ + description: '<b>raw</b> [bad](javascript:alert(1)) [ok](https://example.com)', + }), + }) + + await pressTab('Preview') + + expect(preview()!.querySelector('b')).toBeNull() + expect(text(preview())).toContain('<b>raw</b>') + const links = [...preview()!.querySelectorAll('a')] + expect(links.map((link) => link.getAttribute('href'))).toEqual(['https://example.com']) + }) + + it('reads Nothing to preview yet. when empty', async () => { + await mountEditor('/content/events/new') + + await pressTab('Preview') + + expect(text(preview())).toBe('Nothing to preview yet.') + }) + + it.each([ + ['Bold', '**lime**'], + ['Italic', '_lime_'], + ['Heading', '## lime'], + ['Link', '[lime](url)'], + ['List', '- lime'], + ])('the %s button turns the selection into %s', async (label, result) => { + await mountEditor('/content/events/new') + await typeInto('description', 'lime') + const area = control('description') as HTMLTextAreaElement + area.focus() + area.setSelectionRange(0, 4) + + toolbarButton(label).click() + await flushPromises() + + expect(area.value).toBe(result) + expect(document.activeElement).toBe(area) + }) + + it('exposes the tabs as a tablist and names the toolbar buttons', async () => { + await mountEditor('/content/events/new') + + expect(document.body.querySelector('[role="tablist"]')).toBeTruthy() + for (const label of ['Bold', 'Italic', 'Heading', 'Link', 'List']) { + expect(toolbarButton(label)).toBeTruthy() + } + }) +}) + +describe('Accessibility', () => { + it('announces the chip politely and ties labels to controls', async () => { + await mountEditor('/content/events/42') + + expect(chip()!.getAttribute('role')).toBe('status') + const label = fieldWrapper('title').querySelector('label')! + expect(label.getAttribute('for')).toBe(control('title').id) + }) +}) diff --git a/web/src/views/__tests__/EntryEditorDelete.spec.ts b/web/src/views/__tests__/EntryEditorDelete.spec.ts new file mode 100644 index 0000000..ba16773 --- /dev/null +++ b/web/src/views/__tests__/EntryEditorDelete.spec.ts @@ -0,0 +1,184 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flushPromises } from '@vue/test-utils' +import { + arrived, + button, + chip, + control, + entryWrites, + failWith, + failWithBody, + hasText, + main, + mountEditor, + networkFailure, + pending, + setField, + text, + typeInto, + unmountAll, +} from './entryEditorKit' + +vi.mock('@/composables/useApi', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/composables/useApi')>() + return { ...actual, useApi: vi.fn() } +}) + +afterEach(unmountAll) + +async function openDialog() { + button('Delete')!.click() + await flushPromises() +} + +function dialog() { + return document.body.querySelector('[role="alertdialog"]') +} + +describe('13 Delete entry: confirm', () => { + it('opens the dialog with no request', async () => { + await mountEditor('/content/events/42') + + await openDialog() + + expect(text(dialog())).toContain('Delete this entry?') + expect(text(dialog())).toContain( + '"Lime plaster workshop" will be deleted from Events. This can\'t be undone.', + ) + expect(button('Cancel')).toBeTruthy() + expect(button('Delete entry')).toBeTruthy() + expect(entryWrites).toEqual([]) + }) + + it('closes on Cancel with nothing sent', async () => { + await mountEditor('/content/events/42') + await openDialog() + + const cancel = [...dialog()!.querySelectorAll('button')].find( + (candidate) => text(candidate) === 'Cancel', + )! + cancel.click() + await flushPromises() + + await vi.waitFor(() => expect(dialog()).toBeNull()) + expect(entryWrites).toEqual([]) + }) + + it('returns focus to Delete after the dialog closes', async () => { + await mountEditor('/content/events/42') + const trigger = button('Delete')! + trigger.focus() + await openDialog() + expect(document.activeElement).not.toBe(button('Delete entry')) + + const cancel = [...dialog()!.querySelectorAll('button')].find( + (candidate) => text(candidate) === 'Cancel', + )! + cancel.click() + await flushPromises() + + await vi.waitFor(() => expect(document.activeElement).toBe(trigger)) + }) + + it('locks the dialog while deleting and sends a single request', async () => { + const gate = pending() + await mountEditor('/content/events/42', { writeEntry: () => gate.promise }) + await openDialog() + + button('Delete entry')!.click() + await flushPromises() + button('Deleting…')!.click() + await flushPromises() + + expect(entryWrites).toEqual([ + { method: 'DELETE', url: '/v1/content-type/events/entry/42', body: {} }, + ]) + expect(button('Deleting…')!.disabled).toBe(true) + expect( + [...dialog()!.querySelectorAll('button')].find( + (candidate) => text(candidate) === 'Cancel', + )!.disabled, + ).toBe(true) + }) + + it('leaves for the list with a toast and no prompt on 204', async () => { + await mountEditor('/content/events/42') + await typeInto('title', 'Edited but not saved') + await openDialog() + expect(text(dialog())).toContain('"Lime plaster workshop" will be deleted') + + button('Delete entry')!.click() + + await arrived('/content/events') + expect(hasText('Entry deleted')).toBe(true) + expect(hasText('"Lime plaster workshop" was deleted from Events.')).toBe(true) + expect(hasText('Leave without saving?')).toBe(false) + }) + + it.each([ + ['entry_not_found', 'Entry not found'], + ['content_type_not_found', 'Content type not found'], + ])('shows the page for a 404 %s', async (body, shown) => { + await mountEditor('/content/events/42', { writeEntry: failWithBody(404, body) }) + await openDialog() + + button('Delete entry')!.click() + await flushPromises() + + expect(dialog()).toBeNull() + expect(text(main())).toContain(shown) + }) + + it('shows the access page for a 403', async () => { + await mountEditor('/content/events/42', { writeEntry: failWith(403) }) + await openDialog() + + button('Delete entry')!.click() + await flushPromises() + + expect(text(main())).toContain("You don't have access to Events") + }) + + it.each([ + ['500', failWith(500)], + ['a network failure', networkFailure], + ])('keeps the dialog with an alert on %s', async (_name, failure) => { + await mountEditor('/content/events/42', { writeEntry: failure }) + await openDialog() + + button('Delete entry')!.click() + await flushPromises() + + expect(text(dialog())).toContain("Couldn't delete the entry") + expect(text(dialog())).toContain( + 'Something went wrong on our side. Nothing was deleted. Try again in a moment.', + ) + expect(button('Delete entry')!.disabled).toBe(false) + expect( + [...dialog()!.querySelectorAll('button')].find( + (candidate) => text(candidate) === 'Cancel', + )!.disabled, + ).toBe(false) + }) + + it('shows the trash action on mobile that opens the same dialog', async () => { + await mountEditor('/content/events/42', { width: 390 }) + + document.body.querySelector<HTMLButtonElement>('button[aria-label="Delete"]')!.click() + await flushPromises() + + expect(text(dialog())).toContain('Delete this entry?') + }) + + it('is disabled while an edit is saving', async () => { + const gate = pending() + const wrapper = await mountEditor('/content/events/42', { writeEntry: () => gate.promise }) + await setField(wrapper, 'capacity', 20) + document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click() + await flushPromises() + + expect(button('Delete')!.disabled).toBe(true) + expect(text(chip())).toBe('Saving…') + expect(control('title').disabled).toBe(true) + }) +}) diff --git a/web/src/views/__tests__/EntryEditorFailures.spec.ts b/web/src/views/__tests__/EntryEditorFailures.spec.ts new file mode 100644 index 0000000..d673b21 --- /dev/null +++ b/web/src/views/__tests__/EntryEditorFailures.spec.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flushPromises } from '@vue/test-utils' +import router from '@/router' +import { ApiError } from '@/composables/useApi' +import { + answerWrite, + arrived, + button, + calls, + entryWrites, + failWith, + failWithBody, + messageUnder, + hasText, + main, + mountEditor, + save, + setField, + text, + typeInto, + unmountAll, +} from './entryEditorKit' +import { customTypes } from './schemaTestKit' + +vi.mock('@/composables/useApi', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/composables/useApi')>() + return { ...actual, useApi: vi.fn() } +}) + +afterEach(() => { + vi.restoreAllMocks() + return unmountAll() +}) + +describe('save refusals outside the form', () => { + it('shows the access page when the save answers 403', async () => { + const wrapper = await mountEditor('/content/events/42', { writeEntry: failWith(403) }) + await setField(wrapper, 'capacity', 20) + + await save() + + expect(text(main())).toContain("You don't have access to Events") + }) + + it('shows nothing and keeps the form when the save answers 401', async () => { + const wrapper = await mountEditor('/content/events/42', { writeEntry: failWith(401) }) + await setField(wrapper, 'capacity', 20) + + await save() + + expect(hasText("Couldn't save the entry")).toBe(false) + expect(hasText('Not saved')).toBe(false) + expect(document.body.querySelector('form')).toBeTruthy() + }) + + it('clears the duplicate alert once the next save goes through', async () => { + let attempts = 0 + await mountEditor('/content/events/42', { + writeEntry: (write) => { + attempts++ + if (attempts === 1) { + throw new ApiError(409, undefined, 'invalid_entry: title: unique') + } + return answerWrite(write) + }, + }) + await typeInto('title', 'Taken title') + await save() + expect(hasText('Change it and save again.')).toBe(true) + + await save() + + expect(entryWrites).toHaveLength(2) + expect(hasText('Change it and save again.')).toBe(false) + }) +}) + +describe('required boolean', () => { + it('shows the required message only from a mapped server answer', async () => { + const wrapper = await mountEditor('/content/events/42', { + writeEntry: failWithBody(422, 'invalid_entry: online: required'), + }) + await setField(wrapper, 'capacity', 20) + + await save() + + expect(entryWrites).toHaveLength(1) + expect(messageUnder('online')).toBe('Choose yes or no for Online.') + }) +}) + +describe('schema changed notice', () => { + it.each([ + ['invalid_entry: room: required', 'Room'], + ['invalid_entry: colour: unknown_field', 'Colour'], + ])('names the field of %s as %s', async (body, name) => { + const wrapper = await mountEditor('/content/events/42', { + writeEntry: failWithBody(422, body), + }) + await setField(wrapper, 'capacity', 20) + + await save() + + expect(text(document.body)).toContain(`a new required field, ${name}, was added`) + }) +}) + +describe('author names across a save', () => { + it('keeps the names and looks nobody up again', async () => { + const wrapper = await mountEditor('/content/events/42') + const lookups = () => calls.filter((url) => url.startsWith('/v1/user/lookup')).length + expect(lookups()).toBe(1) + await setField(wrapper, 'capacity', 20) + + await save() + + expect(lookups()).toBe(1) + expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain( + 'Updated by Marta Alves', + ) + }) +}) + +describe('navigation after a save', () => { + it('replaces the URL after a create and never pushes', async () => { + const replace = vi.spyOn(router, 'replace') + const push = vi.spyOn(router, 'push') + const wrapper = await mountEditor('/content/events/new') + await typeInto('title', 'Open day') + await setField(wrapper, 'starts_on', '2026-11-21') + push.mockClear() + + await save() + + await arrived('/content/events/43') + expect(replace).toHaveBeenCalledWith('/content/events/43') + expect(push).not.toHaveBeenCalled() + }) + + it('releases the adopted route when the replace is cancelled', async () => { + let blocking = true + const remove = router.beforeEach((to) => + blocking && to.path === '/content/events/43' ? false : true, + ) + const wrapper = await mountEditor('/content/events/new') + await typeInto('title', 'Open day') + await setField(wrapper, 'starts_on', '2026-11-21') + await save() + expect(router.currentRoute.value.path).toBe('/content/events/new') + const before = calls.filter((url) => url.endsWith('/entry/43')).length + blocking = false + + await router.push('/content/events/43') + await flushPromises() + + remove() + expect(calls.filter((url) => url.endsWith('/entry/43')).length).toBe(before + 1) + }) + + it('leaves the URL alone after an edit', async () => { + const replace = vi.spyOn(router, 'replace') + const wrapper = await mountEditor('/content/events/42') + await setField(wrapper, 'capacity', 20) + + await save() + + expect(entryWrites).toHaveLength(1) + expect(replace).not.toHaveBeenCalled() + }) +}) + +describe('content type load failure', () => { + it('names the type request and retries only the type', async () => { + let attempts = 0 + await mountEditor('/content/events/42', { + type: (key) => { + attempts++ + if (attempts === 1) { + throw new ApiError(500) + } + return customTypes.find((candidate) => candidate.key === key) + }, + }) + + expect(text(main())).toContain("Couldn't load the entry") + expect(text(main())).toContain( + 'GET /v1/content-type/events failed. Check your connection and try again.', + ) + const entryRequests = calls.filter((url) => url.endsWith('/entry/42')).length + + button('Try again')!.click() + await vi.waitFor(() => expect(document.body.querySelector('form')).toBeTruthy()) + + expect(attempts).toBe(2) + expect(calls.filter((url) => url.endsWith('/entry/42')).length).toBe(entryRequests) + }) +}) diff --git a/web/src/views/__tests__/EntryEditorLeave.spec.ts b/web/src/views/__tests__/EntryEditorLeave.spec.ts new file mode 100644 index 0000000..fab1916 --- /dev/null +++ b/web/src/views/__tests__/EntryEditorLeave.spec.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flushPromises } from '@vue/test-utils' +import router from '@/router' +import { + arrived, + button, + chip, + failWithBody, + hasText, + mountEditor, + setField, + text, + typeInto, + unmountAll, +} from './entryEditorKit' + +vi.mock('@/composables/useApi', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/composables/useApi')>() + return { ...actual, useApi: vi.fn() } +}) + +afterEach(unmountAll) + +function breadcrumbLink(label: string) { + return [ + ...document.body.querySelectorAll<HTMLAnchorElement>('nav[aria-label="breadcrumb"] a'), + ].find((candidate) => text(candidate) === label)! +} + +async function prompted() { + await vi.waitFor(() => expect(hasText('Leave without saving?')).toBe(true)) +} + +describe('Leaving with unsaved edits', () => { + it('navigates with no prompt when nothing changed', async () => { + await mountEditor('/content/events/42') + + breadcrumbLink('Events').click() + + await arrived('/content/events') + expect(hasText('Leave without saving?')).toBe(false) + }) + + it('prompts after an edit and stays on Keep editing', async () => { + await mountEditor('/content/events/42') + await typeInto('title', 'Changed') + + breadcrumbLink('Events').click() + await prompted() + expect(hasText('Your changes to "Lime plaster workshop" haven\'t been saved.')).toBe(true) + button('Keep editing')!.click() + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/content/events/42') + }) + + it('leaves on Discard changes', async () => { + await mountEditor('/content/events/42') + await typeInto('title', 'Changed') + + breadcrumbLink('Events').click() + await prompted() + button('Discard changes')!.click() + + await arrived('/content/events') + }) + + it('prompts for a typed value on a new entry but not for untouched defaults', async () => { + await mountEditor('/content/events/new') + breadcrumbLink('Events').click() + await arrived('/content/events') + expect(hasText('Leave without saving?')).toBe(false) + + await router.push('/content/events/new') + await flushPromises() + await typeInto('title', 'x') + breadcrumbLink('Events').click() + await prompted() + }) + + it('prompts through Cancel too', async () => { + await mountEditor('/content/events/42') + await typeInto('title', 'Changed') + + button('Cancel')!.click() + + await prompted() + }) + + it('does not prompt after a save', async () => { + const wrapper = await mountEditor('/content/events/42') + await setField(wrapper, 'capacity', 20) + document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click() + await flushPromises() + + breadcrumbLink('Events').click() + + await arrived('/content/events') + expect(hasText('Leave without saving?')).toBe(false) + }) + + it('prompts in the schema changed state with typed values', async () => { + const wrapper = await mountEditor('/content/events/new', { + writeEntry: failWithBody(422, 'invalid_entry: room: required'), + }) + await typeInto('title', 'Open day') + await setField(wrapper, 'starts_on', '2026-11-21') + document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click() + await flushPromises() + + expect(text(chip())).toBe('Not saved') + breadcrumbLink('Events').click() + await prompted() + }) +}) + +describe('Cancel history', () => { + it('goes back to the list with its query when it was the previous page', async () => { + await router.push('/content/events?sort=id:asc') + await mountEditor('/content/events/42') + await router.push('/content/events/42') + await flushPromises() + + button('Cancel')!.click() + + await arrived('/content/events?sort=id:asc') + }) + + it('navigates to the list when the previous page is something else', async () => { + await router.push('/home') + await mountEditor('/content/events/42') + + button('Cancel')!.click() + + await arrived('/content/events') + }) +}) diff --git a/web/src/views/__tests__/EntryEditorLoad.spec.ts b/web/src/views/__tests__/EntryEditorLoad.spec.ts new file mode 100644 index 0000000..b146fc2 --- /dev/null +++ b/web/src/views/__tests__/EntryEditorLoad.spec.ts @@ -0,0 +1,363 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import router from '@/router' +import { flushPromises } from '@vue/test-utils' +import { + MOBILE_WIDTH, + arrived, + button, + calls, + chip, + control, + customTypes, + entry42, + entryWith, + eventsFields, + failWith, + failWithBody, + fieldWrapper, + hasText, + setField, + typeInto, + main, + mountEditor, + networkFailure, + pending, + text, + unmountAll, +} from './entryEditorKit' + +vi.mock('@/composables/useApi', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/composables/useApi')>() + return { ...actual, useApi: vi.fn() } +}) + +afterEach(unmountAll) + +function entryRequests() { + return calls.filter((url) => /\/entry\/\d+$/.test(url)) +} + +describe('1 New entry', () => { + it('shows the title, the subtitle, the chip and the actions', async () => { + await mountEditor('/content/events/new') + + expect(text(main())).toContain('New entry Events · Fields marked * are required.') + expect(text(chip())).toBe('New entry') + expect(button('Cancel')).toBeTruthy() + expect(button('Save')!.disabled).toBe(false) + expect(button('Delete')).toBeUndefined() + expect(entryRequests()).toEqual([]) + }) + + it('marks required labels and shows helps, placeholders and defaults', async () => { + await mountEditor('/content/events/new') + + expect(text(fieldWrapper('title'))).toContain('Title *') + expect(text(fieldWrapper('starts_on'))).toContain('Starts on *') + expect(text(fieldWrapper('ends_on'))).not.toContain('*') + expect(text(fieldWrapper('title'))).toContain('Each entry needs a different title.') + expect(text(fieldWrapper('capacity'))).toContain('Between 1 and 5000.') + expect(control('capacity').getAttribute('placeholder')).toBe('Enter a number') + expect(text(fieldWrapper('category'))).toContain('Choose an option') + expect(text(fieldWrapper('starts_on'))).toContain('Pick a date') + expect(text(fieldWrapper('online'))).toContain('Off') + expect(text(fieldWrapper('online'))).toContain('Default') + expect(text(fieldWrapper('title'))).not.toContain('Default') + }) + + it('shows the footer of an unsaved entry', async () => { + await mountEditor('/content/events/new') + + expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toBe( + 'Entry Not saved yet Created by – Updated by –', + ) + }) + + it('reads Content, Events and New entry in the breadcrumb', async () => { + await mountEditor('/content/events/new') + + expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe( + 'Content Events New entry', + ) + }) + + it('has no required marker for assistive technology', async () => { + await mountEditor('/content/events/new') + + const star = fieldWrapper('title').querySelector('[aria-hidden="true"]') + expect(star?.textContent?.trim()).toBe('*') + expect(control('title').getAttribute('aria-required')).toBe('true') + }) +}) + +describe('2 Editing', () => { + it('shows the entry, the chip, the actions and the footer', async () => { + await mountEditor('/content/events/42') + + expect(text(main())).toContain('Lime plaster workshop Events · Entry 42') + expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe( + 'Content Events Lime plaster workshop', + ) + expect(text(chip())).toBe('Saved') + expect(button('Delete')).toBeTruthy() + expect(button('Save')!.disabled).toBe(true) + expect(control('title').value).toBe('Lime plaster workshop') + expect(control('capacity').value).toBe('18') + expect(text(fieldWrapper('online'))).not.toContain('Default') + expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toBe( + 'Entry events / 42 Created by Inès Duarte · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02', + ) + }) + + it('sends the entry request with the type request and the lookup once', async () => { + await mountEditor('/content/events/42') + + expect(calls).toContain('/v1/content-type/events') + expect(calls).toContain('/v1/content-type/events/entry/42') + expect(calls.filter((url) => url.startsWith('/v1/user/lookup'))).toEqual([ + '/v1/user/lookup?ids=4,3', + ]) + }) + + it('reads a dash and keeps the date when the lookup fails or lacks an id', async () => { + await mountEditor('/content/events/42', { + lookup: () => [{ id: 3, display_name: 'Marta Alves' }], + }) + + expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain( + 'Created by – · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02', + ) + }) + + it('reads dashes when the lookup fails', async () => { + await mountEditor('/content/events/42', { lookup: failWith(500) }) + + expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain( + 'Updated by – · 15 Oct 2026, 11:02', + ) + }) + + it('shows a trash action on mobile and no Delete button', async () => { + await mountEditor('/content/events/42', { width: MOBILE_WIDTH }) + + expect(button('Delete')).toBeUndefined() + expect(document.body.querySelector('button[aria-label="Delete"]')).toBeTruthy() + expect(button('Cancel')).toBeTruthy() + expect(button('Save')!.disabled).toBe(true) + }) + + it('shows the new entry layout on mobile without an action', async () => { + await mountEditor('/content/events/new', { width: MOBILE_WIDTH }) + + expect(document.body.querySelector('button[aria-label="Delete"]')).toBeNull() + expect(button('Cancel')).toBeTruthy() + expect(text(chip())).toBe('New entry') + }) +}) + +describe('The page title', () => { + it.each([ + ['text', { title: 'Lime plaster workshop' }, 'Lime plaster workshop'], + ['null', { title: null }, 'Entry 42'], + ['number', { title: 18 }, '18'], + ])('reads %s', async (_name, data, title) => { + await mountEditor('/content/events/42', { entry: () => entryWith(data) }) + + expect(text(main().querySelector('h1'))).toBe(title) + }) + + it('reads the first line of rich text', async () => { + const types = customTypes.map((type) => + type.key === 'events' + ? { ...type, fields: [{ ...eventsFields[6]!, key: 'body', label: 'Body' }] } + : type, + ) + await mountEditor('/content/events/42', { + type: () => types[0], + entry: () => entryWith({ body: 'First line\n\nSecond' }), + }) + + expect(text(main().querySelector('h1'))).toBe('First line') + }) +}) + +describe('Defaults', () => { + it.each([ + ['Capacity', 'capacity', { default: 100 }, '100'], + ['Title', 'title', { default: 'Untitled' }, 'Untitled'], + ])('pre-fills and tags %s', async (_label, key, rest, shown) => { + const fields = eventsFields.map((field) => + field.key === key ? { ...field, ...rest } : field, + ) + await mountEditor('/content/events/new', { + type: () => ({ ...customTypes[0]!, fields }), + }) + + expect(control(key).value).toBe(shown) + expect(text(fieldWrapper(key))).toContain('Default') + }) +}) + +describe('Create-only defaults', () => { + const withDefaults = eventsFields.map((field) => + field.key === 'capacity' + ? { ...field, default: 100 } + : field.key === 'title' + ? { ...field, default: 'Untitled' } + : field, + ) + const type = () => ({ ...customTypes[0]!, fields: withDefaults }) + + it('pre-fills and tags every default on a new entry', async () => { + await mountEditor('/content/events/new', { type }) + + expect(control('capacity').value).toBe('100') + expect(control('title').value).toBe('Untitled') + expect(text(fieldWrapper('capacity'))).toContain('Default') + expect(text(fieldWrapper('title'))).toContain('Default') + }) + + it('never pre-fills or tags a default on an existing entry', async () => { + await mountEditor('/content/events/42', { + type, + entry: () => entryWith({ capacity: null, title: 'Stored' }), + }) + + expect(control('capacity').value).toBe('') + expect(control('title').value).toBe('Stored') + expect(text(main())).not.toContain('Default') + }) +}) + +describe('Session not ready', () => { + it('shows the shell and issues no request', async () => { + await mountEditor('/content/events/42', { status: 'checking' }) + + expect(calls.filter((url) => url.includes('/entry') || url.includes('lookup'))).toEqual([]) + expect(document.body.querySelector('[data-entry-field]')).toBeNull() + }) +}) + +describe('Navigating between entries', () => { + const entry = (_key: string, id: number) => + entryWith({ title: `Entry title ${id}`, capacity: id }, { id }) + + it('carries no value, error or chip from /42 to /43', async () => { + const wrapper = await mountEditor('/content/events/42', { + entry, + writeEntry: failWith(500), + }) + await setField(wrapper, 'capacity', 20) + document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click() + await flushPromises() + await setField(wrapper, 'capacity', 42) + expect(hasText("Couldn't save the entry")).toBe(true) + + await router.push('/content/events/43') + await flushPromises() + + expect(control('title').value).toBe('Entry title 43') + expect(control('capacity').value).toBe('43') + expect(hasText("Couldn't save the entry")).toBe(false) + expect(text(chip())).toBe('Saved') + expect(text(main().querySelector('h1'))).toBe('Entry title 43') + }) + + it('carries nothing from an entry to another type new form', async () => { + await mountEditor('/content/events/42', { entry }) + await typeInto('title', 'Typed here') + + void router.push('/content/recipes/new') + await vi.waitFor(() => expect(hasText('Leave without saving?')).toBe(true)) + button('Discard changes')!.click() + await arrived('/content/recipes/new') + await flushPromises() + + expect(control('title').value).toBe('') + expect(text(chip())).toBe('New entry') + expect(text(main().querySelector('h1'))).toBe('New entry') + expect(hasText('Typed here')).toBe(false) + }) +}) + +describe('Entry not found and load failures', () => { + it.each(['abc', '4294967296', '-1'])( + 'shows Entry not found for /content/events/%s with no entry request', + async (id) => { + await mountEditor(`/content/events/${id}`) + + expect(text(main())).toContain('Entry not found') + expect(text(main())).toContain('It may have been deleted by someone else.') + expect(entryRequests()).toEqual([]) + expect(button('Back to Events')).toBeTruthy() + }, + ) + + it('sends id 0 as is and shows Entry not found when the API answers 404', async () => { + await mountEditor('/content/events/0', { entry: failWithBody(404, 'entry_not_found') }) + + expect(entryRequests()).toEqual(['/v1/content-type/events/entry/0']) + expect(text(main())).toContain('Entry not found') + }) + + it('shows Entry not found on 404 entry_not_found and goes back to the list', async () => { + await mountEditor('/content/events/999', { entry: failWithBody(404, 'entry_not_found') }) + + expect(text(main())).toContain('Entry not found') + button('Back to Events')!.click() + await arrived('/content/events') + }) + + it('shows the content type page on 404 content_type_not_found', async () => { + await mountEditor('/content/events/42', { + type: failWithBody(404, 'content_type_not_found'), + entry: failWithBody(404, 'content_type_not_found'), + }) + + expect(text(main())).toContain('Content type not found') + }) + + it('shows the access page on 403', async () => { + await mountEditor('/content/events/42', { entry: failWith(403) }) + + expect(text(main())).toContain("You don't have access to Events") + }) + + it.each([ + ['500', failWith(500)], + ['a network failure', networkFailure], + ])('shows the load error on %s and retries only the entry', async (_name, failure) => { + let attempts = 0 + await mountEditor('/content/events/42', { + entry: () => { + attempts++ + if (attempts === 1) { + return failure() + } + return entry42 + }, + }) + + expect(text(main())).toContain("Couldn't load the entry") + expect(text(main())).toContain( + 'GET /v1/content-type/events/entry/42 failed. Check your connection and try again.', + ) + expect(document.body.querySelector('form')).toBeNull() + const typeRequests = calls.filter((url) => url === '/v1/content-type/events').length + + button('Try again')!.click() + await vi.waitFor(() => expect(control('title').value).toBe('Lime plaster workshop')) + + expect(calls.filter((url) => url === '/v1/content-type/events').length).toBe(typeRequests) + }) + + it('shows a skeleton with aria-busy while loading', async () => { + const gate = pending() + await mountEditor('/content/events/42', { entry: () => gate.promise }) + + const skeleton = document.body.querySelector('[data-testid="entry-editor-skeleton"]') + expect(skeleton?.getAttribute('aria-busy')).toBe('true') + expect(button('Save')).toBeUndefined() + expect(button('Delete')).toBeUndefined() + }) +}) diff --git a/web/src/views/__tests__/EntryEditorSave.spec.ts b/web/src/views/__tests__/EntryEditorSave.spec.ts new file mode 100644 index 0000000..e363ff3 --- /dev/null +++ b/web/src/views/__tests__/EntryEditorSave.spec.ts @@ -0,0 +1,506 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flushPromises } from '@vue/test-utils' +import { formatClock } from '@/lib/updateFormat' +import { + answerWrite, + arrived, + button, + calls, + chip, + control, + customTypes, + entryWith, + eventsFields, + entryWrites, + failWith, + failWithBody, + fieldWrapper, + hasText, + messageUnder, + mountEditor, + networkFailure, + pending, + save, + setField, + text, + typeInto, + unmountAll, +} from './entryEditorKit' + +vi.mock('@/composables/useApi', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/composables/useApi')>() + return { ...actual, useApi: vi.fn() } +}) + +afterEach(unmountAll) + +const SAVED_CLOCK = formatClock(new Date('2026-10-19T11:02:00')) + +async function fillRequired(wrapper: Awaited<ReturnType<typeof mountEditor>>) { + await typeInto('title', 'Open day') + await setField(wrapper, 'starts_on', '2026-11-21') +} + +describe('Payloads', () => { + it('sends every field of a create, with explicit nulls', async () => { + const wrapper = await mountEditor('/content/events/new') + await fillRequired(wrapper) + + await save() + + expect(entryWrites).toHaveLength(1) + expect(entryWrites[0]).toMatchObject({ + method: 'POST', + url: '/v1/content-type/events/entry', + body: { + data: { + title: 'Open day', + starts_on: '2026-11-21', + ends_on: null, + online: false, + capacity: null, + category: null, + description: null, + }, + }, + }) + }) + + it('sends every field of an edit with the unchanged loaded values', async () => { + const wrapper = await mountEditor('/content/events/42', { + entry: () => entryWith({ removed: 'x' }), + }) + await setField(wrapper, 'capacity', 20) + + await save() + + expect(entryWrites).toHaveLength(1) + expect(entryWrites[0]).toMatchObject({ + method: 'PUT', + url: '/v1/content-type/events/entry/42', + }) + const data = entryWrites[0]!.body.data as Record<string, unknown> + expect(data.capacity).toBe(20) + expect(data.title).toBe('Lime plaster workshop') + expect(Object.keys(data)).toEqual([ + 'title', + 'starts_on', + 'ends_on', + 'online', + 'capacity', + 'category', + 'description', + ]) + }) + + it('keeps leading and trailing spaces of a text', async () => { + const wrapper = await mountEditor('/content/events/new') + await fillRequired(wrapper) + await typeInto('title', ' spaced ') + + await save() + + expect((entryWrites[0]!.body.data as Record<string, unknown>).title).toBe(' spaced ') + }) +}) + +describe('Untouched optional boolean', () => { + const noDefault = [ + ...eventsFields.map((field) => + field.key === 'online' ? { ...field, default: null } : field, + ), + ] + + it('sends a create with a null boolean and shows no type message', async () => { + const wrapper = await mountEditor('/content/events/new', { + type: () => ({ ...customTypes[0]!, fields: noDefault }), + }) + await fillRequired(wrapper) + + await save() + + expect(entryWrites).toHaveLength(1) + expect((entryWrites[0]!.body.data as Record<string, unknown>).online).toBeNull() + expect(messageUnder('online')).toBe('') + expect(hasText('Choose yes or no')).toBe(false) + }) + + it('sends an edit with a stored null boolean and shows no type message', async () => { + const wrapper = await mountEditor('/content/events/42', { + entry: () => entryWith({ online: null }), + }) + await setField(wrapper, 'capacity', 20) + + await save() + + expect(entryWrites).toHaveLength(1) + expect((entryWrites[0]!.body.data as Record<string, unknown>).online).toBeNull() + expect(messageUnder('online')).toBe('') + expect(text(chip())).toBe(`Saved at ${SAVED_CLOCK}`) + }) +}) + +describe('The heading while typing', () => { + function headings() { + return [ + text(document.body.querySelector('#dashboard-content h1')), + text(document.body.querySelector('nav[aria-label="breadcrumb"]')), + ] + } + + it('keeps the h1 and the breadcrumb until a save answers', async () => { + await mountEditor('/content/events/42') + const before = headings() + expect(before[0]).toBe('Lime plaster workshop') + + await typeInto('title', 'Brand new name') + + expect(headings()).toEqual(before) + + await save() + + expect(headings()[0]).toBe('Brand new name') + expect(headings()[1]).toContain('Brand new name') + }) +}) + +describe('5 Saving', () => { + it('dims the form, disables the controls and sends one request', async () => { + const gate = pending() + const wrapper = await mountEditor('/content/events/42', { writeEntry: () => gate.promise }) + await setField(wrapper, 'capacity', 20) + + await save() + await save() + control('title') + .closest('form')! + .dispatchEvent(new Event('submit', { cancelable: true })) + await flushPromises() + + expect(entryWrites).toHaveLength(1) + expect(text(chip())).toBe('Saving…') + expect(button('Saving…')!.disabled).toBe(true) + expect(button('Cancel')!.disabled).toBe(true) + expect(button('Delete')!.disabled).toBe(true) + expect(control('title').disabled).toBe(true) + expect(document.body.querySelector('form')!.className).toContain('opacity-70') + expect(document.body.querySelector('form')!.getAttribute('aria-busy')).toBe('true') + }) +}) + +describe('6 Saved', () => { + it('shows the toast, the chip and the answered values after an edit', async () => { + const wrapper = await mountEditor('/content/events/42') + await typeInto('title', 'Lime plaster workshop, two days') + + await save() + + expect(hasText('Entry saved')).toBe(true) + expect(hasText('"Lime plaster workshop, two days" is up to date.')).toBe(true) + expect(text(chip())).toBe(`Saved at ${SAVED_CLOCK}`) + expect(button('Save')!.disabled).toBe(true) + expect(text(document.body.querySelector('h1'))).toBe('Lime plaster workshop, two days') + expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain( + 'Updated by Marta Alves · 19 Oct 2026, 11:02', + ) + await setField(wrapper, 'capacity', 21) + expect(text(chip())).toBe('Unsaved changes') + }) + + it('replaces the URL after a create without a new entry request', async () => { + const wrapper = await mountEditor('/content/events/new') + await fillRequired(wrapper) + const before = calls.length + + await save() + + await arrived('/content/events/43') + expect(calls.slice(before).filter((url) => /\/entry\/\d+$/.test(url))).toEqual([]) + expect(calls.slice(before).filter((url) => url === '/v1/content-type/events')).toEqual([]) + expect(text(chip())).toBe(`Saved at ${SAVED_CLOCK}`) + expect(button('Delete')).toBeTruthy() + expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain( + 'events / 43', + ) + }) + + it('reverting an edit returns to Saved and disables Save', async () => { + await mountEditor('/content/events/42') + await typeInto('title', 'x') + expect(text(chip())).toBe('Unsaved changes') + expect(button('Save')!.disabled).toBe(false) + + await typeInto('title', 'Lime plaster workshop') + + expect(text(chip())).toBe('Saved') + expect(button('Save')!.disabled).toBe(true) + }) +}) + +describe('7 Save failed', () => { + it.each([ + ['500', failWith(500)], + ['a network failure', networkFailure], + ['400', failWith(400)], + ['422 with no field', failWithBody(422, 'invalid_entry')], + ])('shows the alert on %s and sends the same body again', async (_name, failure) => { + let attempts = 0 + const wrapper = await mountEditor('/content/events/42', { + writeEntry: (write) => { + attempts++ + return attempts === 1 ? failure() : answerWrite(write) + }, + }) + await setField(wrapper, 'capacity', 20) + + await save() + + expect(text(document.body)).toContain("Couldn't save the entry") + expect(text(document.body)).toContain( + 'Something went wrong on our side. Your changes are still here. Try again in a moment.', + ) + expect(text(chip())).toBe('Save failed') + expect(button('Save')!.disabled).toBe(false) + expect(document.body.querySelector('form')!.className).not.toContain('opacity-70') + + button('Try again')!.click() + await flushPromises() + + expect(entryWrites).toHaveLength(2) + expect(entryWrites[1]!.body).toEqual(entryWrites[0]!.body) + expect(text(document.body)).not.toContain("Couldn't save the entry") + }) +}) + +describe('8 Required fields empty', () => { + it('sends nothing, shows every message and focuses the first field', async () => { + await mountEditor('/content/events/new') + + await save() + + expect(entryWrites).toEqual([]) + expect(messageUnder('title')).toBe('Fill in Title.') + expect(messageUnder('starts_on')).toBe('Pick a date for Starts on.') + expect(text(chip())).toBe('Not saved') + expect(document.activeElement).toBe(control('title')) + expect(control('title').getAttribute('aria-invalid')).toBe('true') + expect(control('title').getAttribute('aria-describedby')).toBeTruthy() + }) + + it('clears a message when its field is edited and validates nothing on blur', async () => { + await mountEditor('/content/events/new') + await save() + + await typeInto('title', 'Open day') + + expect(messageUnder('title')).toBe('') + expect(messageUnder('starts_on')).toBe('Pick a date for Starts on.') + expect(text(chip())).toBe('Unsaved changes') + control('title').dispatchEvent(new Event('blur')) + await flushPromises() + expect(messageUnder('title')).toBe('') + }) +}) + +describe('9 Rule errors', () => { + it('shows one message per field with the counter', async () => { + const wrapper = await mountEditor('/content/events/42') + await typeInto('title', 'x'.repeat(91)) + await setField(wrapper, 'capacity', 6000) + await setField(wrapper, 'category', 'Seminar') + + await save() + + expect(entryWrites).toEqual([]) + expect(messageUnder('title')).toBe('Title can be up to 80 characters.') + expect(messageUnder('capacity')).toBe('Capacity must be at most 5000.') + expect(messageUnder('category')).toBe('Choose one of: Talk, Workshop or Meetup.') + expect(text(fieldWrapper('title'))).toContain('91 / 80') + expect( + fieldWrapper('title').querySelector('[data-testid="entry-counter"]')!.className, + ).toContain('text-error') + expect(text(chip())).toBe('Not saved') + expect(document.activeElement).toBe(control('title')) + }) + + it.each([ + ['title', 'min', 'Title needs at least 3 characters.'], + ['capacity', 'max', 'Capacity must be at most 5000.'], + ['ends_on', 'type', 'Ends on must be a date, like 2026-11-21.'], + ['category', 'options', 'Choose one of: Talk, Workshop or Meetup.'], + ['title', 'required', 'Fill in Title.'], + ])('maps a server answer %s %s onto its field', async (key, rule, message) => { + const wrapper = await mountEditor('/content/events/42', { + writeEntry: failWithBody(422, `invalid_entry: ${key}: ${rule}`), + type: () => ({ + id: 1, + key: 'events', + name: 'Events', + icon: null, + created_at: '2026-09-01T09:30:00Z', + updated_at: '2026-09-01T09:30:00Z', + fields: [ + { + key: 'title', + label: 'Title', + type: 'text', + required: true, + unique: false, + default: null, + min: 3, + max: 80, + options: [], + }, + { + key: 'ends_on', + label: 'Ends on', + type: 'date', + required: false, + unique: false, + default: null, + min: null, + max: null, + options: [], + }, + { + key: 'capacity', + label: 'Capacity', + type: 'number', + required: false, + unique: false, + default: null, + min: 1, + max: 5000, + options: [], + }, + { + key: 'category', + label: 'Category', + type: 'select', + required: false, + unique: false, + default: null, + min: null, + max: null, + options: ['Talk', 'Workshop', 'Meetup'], + }, + ], + }), + entry: () => entryWith({}), + }) + await setField(wrapper, 'title', 'Open day') + + await save() + + expect(entryWrites).toHaveLength(1) + expect(messageUnder(key)).toBe(message) + expect(text(chip())).toBe('Not saved') + }) +}) + +describe('10 Duplicate unique value (409)', () => { + it('shows the alert and the field message, and clears the alert on edit', async () => { + const wrapper = await mountEditor('/content/events/new', { + writeEntry: failWithBody(409, 'invalid_entry: title: unique'), + }) + await fillRequired(wrapper) + await typeInto('title', 'Heat pumps: what the numbers say') + + await save() + + expect(text(document.body)).toContain( + 'Another entry already uses this title. Change it and save again.', + ) + expect(messageUnder('title')).toBe( + 'Another entry already uses this Title. Enter a different one.', + ) + expect(text(chip())).toBe('Not saved') + expect(document.activeElement).toBe(control('title')) + expect(control('title').value).toBe('Heat pumps: what the numbers say') + + await typeInto('title', 'Heat pumps') + + expect(text(document.body)).not.toContain('Change it and save again.') + }) +}) + +describe('11 Entry not found on save', () => { + it('shows Entry not found when the PUT answers 404 entry_not_found', async () => { + const wrapper = await mountEditor('/content/events/42', { + writeEntry: failWithBody(404, 'entry_not_found'), + }) + await setField(wrapper, 'capacity', 20) + + await save() + + expect(text(document.body)).toContain('Entry not found') + button('Back to Events')!.click() + await arrived('/content/events') + }) +}) + +describe('12 Schema changed since load', () => { + it.each([ + ['invalid_entry: room: required', 422], + ['invalid_entry: description: unknown_field', 422], + ['invalid_entry: colour: unknown_field', 422], + ['invalid_entry: room: unique', 409], + ])('shows the read-only form on %s', async (body, status) => { + const wrapper = await mountEditor('/content/events/new', { + writeEntry: failWithBody(status, body), + }) + await fillRequired(wrapper) + + await save() + + expect(text(document.body)).toContain('The fields of Events changed') + expect(text(chip())).toBe('Not saved') + expect(button('Save')!.disabled).toBe(true) + expect(button('Cancel')!.disabled).toBe(false) + expect(control('title').disabled).toBe(true) + expect(control('title').value).toBe('Open day') + }) + + it('rebuilds the form from the new type when Reload form is pressed', async () => { + let reloads = 0 + const wrapper = await mountEditor('/content/events/new', { + writeEntry: failWithBody(422, 'invalid_entry: room: required'), + type: (key) => { + reloads++ + return { + id: 1, + key, + name: 'Events', + icon: null, + created_at: '2026-09-01T09:30:00Z', + updated_at: '2026-09-01T09:30:00Z', + fields: [ + { + key: 'title', + label: 'Title', + type: 'text', + required: true, + unique: false, + default: null, + min: null, + max: null, + options: [], + }, + ], + } + }, + }) + await typeInto('title', 'Open day') + await save() + expect(reloads).toBe(1) + + button('Reload form')!.click() + await flushPromises() + + expect(reloads).toBe(2) + expect(control('title').value).toBe('') + expect(text(chip())).toBe('New entry') + expect(text(document.body)).not.toContain('The fields of Events changed') + expect(wrapper.exists()).toBe(true) + }) +}) diff --git a/web/src/views/__tests__/contentTestKit.ts b/web/src/views/__tests__/contentTestKit.ts index 028b1e6..69dad51 100644 --- a/web/src/views/__tests__/contentTestKit.ts +++ b/web/src/views/__tests__/contentTestKit.ts @@ -150,7 +150,7 @@ export function entry(row: Row) { ] = row return { id, - content_type_id: 1, + content_type: 'events', data: { title, starts_on: startsOn, @@ -181,7 +181,6 @@ export function listPage(items: unknown[], total: number, query: URLSearchParams total, page: Number(query.get('page')), size: Number(query.get('size')), - sort: query.get('sort'), } } diff --git a/web/src/views/__tests__/entryEditorKit.ts b/web/src/views/__tests__/entryEditorKit.ts new file mode 100644 index 0000000..72d66e4 --- /dev/null +++ b/web/src/views/__tests__/entryEditorKit.ts @@ -0,0 +1,157 @@ +import router from '@/router' +import { expect, vi } from 'vitest' +import { flushPromises, type VueWrapper } from '@vue/test-utils' +import EntryField from '@/components/EntryField.vue' +import { addCollection } from '@iconify/vue' +import lucide from 'virtual:lucide-subset' +import { mountAt, text, type EntryWrite, type SchemaMount } from './schemaTestKit' + +export { + button, + calls, + customTypes, + entryWrites, + failWith, + failWithBody, + networkFailure, + pending, + text, + unmountAll, +} from './schemaTestKit' + +export { MOBILE_WIDTH } from '@/components/__tests__/viewport' +export { eventsFields } from '@/lib/__tests__/schemaFixtures' + +addCollection(lucide) + +export const MARTA = 3 +export const INES = 4 +export const JONAS = 5 + +export const entry42 = { + id: 42, + content_type: 'events', + data: { + title: 'Lime plaster workshop', + starts_on: '2026-11-21', + ends_on: '2026-11-22', + online: false, + capacity: 18, + category: 'Workshop', + description: + 'Two days of hands-on work with **lime putty** and old stone.\n\n- Bring work clothes\n- Lunch is included', + }, + created_by: INES, + updated_by: MARTA, + created_at: '2026-10-12T09:14:00', + updated_at: '2026-10-15T11:02:00', +} + +export const userNames = [ + { id: INES, display_name: 'Inès Duarte' }, + { id: MARTA, display_name: 'Marta Alves' }, + { id: JONAS, display_name: 'Jonas Pereira' }, +] + +export function lookupNames(ids: number[]) { + return userNames.filter((user) => ids.includes(user.id)) +} + +export function entryWith(data: Record<string, unknown>, rest: Record<string, unknown> = {}) { + return { ...entry42, ...rest, data: { ...entry42.data, ...data } } +} + +export function answerWrite(write: EntryWrite) { + const data = write.body.data as Record<string, unknown> + const base = write.method === 'POST' ? { ...entry42, id: 43 } : entry42 + return { + ...base, + data, + updated_by: MARTA, + updated_at: '2026-10-19T11:02:00', + } +} + +export function mountEditor(path: string, options: SchemaMount = {}) { + return mountAt(path, { + entry: () => entry42, + writeEntry: answerWrite, + lookup: lookupNames, + ...options, + }) +} + +export async function settle() { + await flushPromises() +} + +export function fieldWrapper(key: string) { + return document.body.querySelector<HTMLElement>(`[data-entry-field="${key}"]`)! +} + +export function control(key: string) { + return fieldWrapper(key).querySelector<HTMLInputElement | HTMLTextAreaElement>( + 'input, textarea', + )! +} + +export async function typeInto(key: string, value: string) { + const element = control(key) + element.value = value + element.dispatchEvent(new Event('input', { bubbles: true })) + await flushPromises() +} + +export function chip() { + return document.body.querySelector('[data-testid="builder-chip"]') +} + +export async function save() { + document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click() + await flushPromises() +} + +export function setField(wrapper: VueWrapper, key: string, value: unknown) { + const owner = wrapper + .findAllComponents(EntryField) + .find((candidate) => candidate.props('field').key === key)! + owner.vm.$emit('update', key, value) + return flushPromises() +} + +export function main() { + return document.body.querySelector('#dashboard-content')! +} + +export function toast() { + return text(document.body.querySelector('[data-part="root"], [role="status"][data-state]')) +} + +export function hasText(value: string) { + return text(document.body).includes(value) +} + +export function messageUnder(key: string) { + return text(fieldWrapper(key).querySelector('[role="alert"]')) +} + +export async function arrived(path: string) { + await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe(path)) +} + +export function installSelectPolyfills() { + Element.prototype.scrollIntoView = vi.fn() + Element.prototype.hasPointerCapture = vi.fn(() => false) + Element.prototype.releasePointerCapture = vi.fn() +} + +export async function openChoices(key: string) { + const trigger = fieldWrapper(key).querySelector('[role="combobox"]')! + trigger.dispatchEvent( + Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), { + pointerType: 'mouse', + }), + ) + await flushPromises() + return [...document.body.querySelectorAll<HTMLElement>('[role="option"]')] +} diff --git a/web/src/views/__tests__/schemaTestKit.ts b/web/src/views/__tests__/schemaTestKit.ts index be81f78..abbb086 100644 --- a/web/src/views/__tests__/schemaTestKit.ts +++ b/web/src/views/__tests__/schemaTestKit.ts @@ -134,7 +134,15 @@ function defaultType(key: string) { return found } +export interface EntryWrite { + method: string + url: string + body: Record<string, unknown> +} + export interface SchemaMount { + entry?: (key: string, id: number) => unknown + writeEntry?: (write: EntryWrite) => unknown role?: string create?: (body: PostedRequest['body']) => unknown update?: (body: PostedRequest['body'], key: string) => unknown @@ -144,6 +152,7 @@ export interface SchemaMount { content?: Responder builtin?: Responder verified?: boolean + status?: 'checking' | 'ready' actions?: string[] type?: (key: string) => unknown list?: (key: string, query: URLSearchParams) => unknown @@ -156,6 +165,8 @@ export const calls: string[] = [] export const posts: PostedRequest[] = [] export const puts: PostedRequest[] = [] export const deletes: string[] = [] +export const entryWrites: EntryWrite[] = [] +const ENTRY_URL = /^\/v1\/content-type\/[^/?]+\/entry(\/\d+)?$/ export function updatedType(key: string, body: Record<string, unknown>) { const base = customTypes.find((type) => type.key === key)! @@ -167,6 +178,7 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly posts.length = 0 puts.length = 0 deletes.length = 0 + entryWrites.length = 0 localStorage.clear() options.before?.() setViewportWidth(options.width ?? DESKTOP_WIDTH) @@ -187,8 +199,17 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly displayName: 'Marta Alves', role: (options.role ?? 'owner') as never, } - session.status = 'ready' + session.status = options.status ?? 'ready' const request = vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method && ENTRY_URL.test(url)) { + const write = { + method: init.method, + url, + body: init.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {}, + } + entryWrites.push(write) + return options.writeEntry?.(write) + } if (init?.method === 'DELETE') { deletes.push(url) return (options.remove ?? (() => undefined))(url.split('/').pop()!) @@ -223,6 +244,10 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly if (looked) { return (options.lookup ?? (() => []))(looked[1]!.split(',').map(Number)) } + const single = url.match(/^\/v1\/content-type\/([^/?]+)\/entry\/(\d+)$/) + if (single) { + return options.entry?.(single[1]!, Number(single[2])) + } const detailed = url.match(/^\/v1\/content-type\/([^/?]+)$/) if (detailed) { return (options.type ?? defaultType)(detailed[1]!) @@ -233,7 +258,10 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly return undefined }) vi.mocked(useApi).mockReturnValue({ - getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta' }), + getMe: + options.status === 'checking' + ? vi.fn(() => new Promise(() => undefined)) + : vi.fn().mockResolvedValue({ verified: true, username: 'marta' }), request, } as unknown as ReturnType<typeof useApi>) await router.push(path) diff --git a/web/visual/check.ts b/web/visual/check.ts index 640e9c6..4d737f2 100644 --- a/web/visual/check.ts +++ b/web/visual/check.ts @@ -649,6 +649,9 @@ const boardGroupLabels: Record<string, Record<string, string>> = { content: { 'Content and entry list': '5 Content and entry list', }, + entry: { + 'Entry editor': '6 Entry editor', + }, } const referenceGroups: Record<string, string> = { diff --git a/web/visual/states.ts b/web/visual/states.ts index 733b67d..d8044fa 100644 --- a/web/visual/states.ts +++ b/web/visual/states.ts @@ -7095,7 +7095,7 @@ const contentRows = [ by, ]) => ({ id, - content_type_id: 1, + content_type: 'events', data: { title, starts_on: startsOn, @@ -7155,10 +7155,9 @@ function contentEntries(url: URL) { total: 37, page: 1, size: 5, - sort: 'updated_at:desc', } } - return { items: contentRows, total: 7, page: 1, size: 25, sort: 'updated_at:desc' } + return { items: contentRows, total: 7, page: 1, size: 25 } } function contentApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> { @@ -7205,7 +7204,7 @@ const contentStates: Record<string, VisualState> = { api: contentApi({ 'GET /v1/content-type/events/entry': { status: 200, - body: { items: [], total: 0, page: 1, size: 25, sort: 'updated_at:desc' }, + body: { items: [], total: 0, page: 1, size: 25 }, }, }), }, @@ -7226,10 +7225,211 @@ const contentStates: Record<string, VisualState> = { }, } +const entryDescription = + 'Two days of hands-on work with **lime putty** and old stone.\n\n- Bring work clothes\n- Lunch is included' + +function entryRecord(data: Record<string, unknown> = {}, rest: Record<string, unknown> = {}) { + return { + id: 42, + content_type: 'events', + data: { + title: 'Lime plaster workshop', + starts_on: '2026-11-21', + ends_on: '2026-11-22', + online: false, + capacity: 18, + category: 'Workshop', + description: entryDescription, + ...data, + }, + created_by: 4, + updated_by: 3, + created_at: '2026-10-12T09:14:00', + updated_at: '2026-10-15T11:02:00', + ...rest, + } +} + +function entryApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> { + return { + 'GET /v1/auth/me': schemaSession('owner'), + 'GET /v1/content-type': { status: 200, body: schemaCustomTypes }, + 'GET /v1/content-type/events': { status: 200, body: contentEventsType }, + 'GET /v1/content-type/events/entry/42': { status: 200, body: entryRecord() }, + 'GET /v1/user/lookup': { status: 200, body: contentUsers }, + ...overrides, + } +} + +const entrySaved = entryRecord({}, { updated_by: 3, updated_at: '2026-10-19T11:02:00' }) + +function entryField(page: Page, key: string) { + return page.locator(`[data-entry-field="${key}"]`) +} + +async function entryAppend(page: Page, key: string, text: string) { + const input = entryField(page, key).locator('input, textarea').first() + await input.click() + await input.press('End') + await input.pressSequentially(text) +} + +async function entrySave(page: Page) { + await page.getByRole('button', { name: /^Save/ }).click() +} + +async function entryCapacityEdit(page: Page) { + const input = entryField(page, 'capacity').getByRole('spinbutton') + await input.fill('18') + await input.press('Tab') +} + +const entryLowCapacity = entryApi({ + 'GET /v1/content-type/events/entry/42': { status: 200, body: entryRecord({ capacity: 1 }) }, +}) + +function entryEdited(overrides: Record<string, ApiMock>): Record<string, ApiMock> { + return { ...entryLowCapacity, ...overrides } +} + +const entryStates: Record<string, VisualState> = { + 'New entry': { path: '/content/events/new', api: entryApi() }, + Editing: { path: '/content/events/42', api: entryApi() }, + 'Unsaved changes': { + path: '/content/events/42', + api: entryApi(), + steps: async (page) => { + await entryAppend(page, 'title', ', two days') + await page.mouse.move(0, 0) + }, + }, + 'Markdown preview': { + path: '/content/events/42', + api: entryApi(), + steps: async (page) => { + await page.getByRole('tab', { name: 'Preview' }).click() + await schemaSettle(page) + }, + }, + Saving: { + path: '/content/events/42', + api: entryApi({ 'PUT /v1/content-type/events/entry/42': { status: 200, hang: true } }), + steps: async (page) => { + await entryAppend(page, 'title', ', two days') + await entrySave(page) + await page.getByText('Saving…').first().waitFor() + await schemaSettle(page) + }, + }, + Saved: { + path: '/content/events/42', + api: entryEdited({ + 'PUT /v1/content-type/events/entry/42': { status: 200, body: entrySaved }, + }), + steps: async (page) => { + await entryCapacityEdit(page) + await entrySave(page) + await page.getByText('Entry saved').waitFor() + await schemaSettle(page) + }, + }, + 'Save failed': { + path: '/content/events/42', + api: entryApi({ 'PUT /v1/content-type/events/entry/42': { status: 500 } }), + steps: async (page) => { + await entryAppend(page, 'title', ', two days') + await entrySave(page) + await page.getByText("Couldn't save the entry").waitFor() + await schemaSettle(page) + }, + }, + 'Required fields empty': { + path: '/content/events/new', + api: entryApi(), + steps: async (page) => { + await entrySave(page) + await page.getByText('Fill in Title.').waitFor() + await schemaSettle(page) + }, + }, + 'Rule errors': { + path: '/content/events/42', + api: entryApi({ + 'GET /v1/content-type/events/entry/42': { + status: 200, + body: entryRecord({ + title: 'Lime plaster workshop, two days of hands-on work with the whole team on site and lunc', + ends_on: '31/11/2026', + capacity: 6000, + category: 'Seminar', + }), + }, + }), + steps: async (page) => { + await entryAppend(page, 'title', 'h') + await entrySave(page) + await page.getByText('Title can be up to 80 characters.').waitFor() + await schemaSettle(page) + }, + }, + 'Duplicate unique value (409)': { + path: '/content/events/42', + api: entryApi({ + 'PUT /v1/content-type/events/entry/42': { + status: 409, + body: 'invalid_entry: title: unique', + }, + }), + steps: async (page) => { + const input = entryField(page, 'title').locator('input') + await input.fill('Heat pumps: what the numbers say') + await entrySave(page) + await page.getByText('Another entry already uses this Title.').first().waitFor() + await schemaSettle(page) + }, + }, + 'Entry not found': { + path: '/content/events/999', + api: entryApi({ + 'GET /v1/content-type/events/entry/999': { status: 404, body: 'entry_not_found' }, + }), + }, + 'Schema changed since load': { + path: '/content/events/42', + api: entryEdited({ + 'PUT /v1/content-type/events/entry/42': { + status: 422, + body: 'invalid_entry: room: required', + }, + }), + steps: async (page) => { + await entryCapacityEdit(page) + await entrySave(page) + await page.getByText('The fields of Events changed').waitFor() + await schemaSettle(page) + }, + exception: { + maxPercent: { desktop: 16, mobile: 13 }, + reason: 'The light board screenshot of this state contains a stray miniature of the delete dialog state, about 12.6 % of the frame on its own', + approvedBy: 'user', + }, + }, + 'Delete entry: confirm': { + path: '/content/events/42', + api: entryApi(), + steps: async (page) => { + await page.getByRole('button', { name: 'Delete', exact: true }).click() + await page.getByRole('alertdialog').waitFor() + await page.mouse.move(0, 0) + }, + }, +} + export const visualStates: Record<string, Record<string, VisualState>> = { sites: sitesStates, schema: schemaStates, content: contentStates, + entry: entryStates, authors: authorsStates, activity: activityStates, profile: {