feat: schema builder's create flow
This commit is contained in:
1 parent
f514146fed
commit
083cc9e23e
71 files changed
+7220
-58
No files matched your search
@@ -0,0 +1,516 @@
|
||||
Feature: Create a custom content type in the Schema builder
|
||||
As an owner or admin
|
||||
I want to define a new content type (name, key and ordered fields) on /schema/new
|
||||
So that users can write entries of that 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 POST /v1/content-type takes {key, name, fields[{key, label, type, required, unique, default, min, max, options}]}
|
||||
And the builder never sends an "icon" key
|
||||
And POST answers 201 with the content type, 400 invalid_content_type_key, 409 content_type_key_taken, 422 invalid_field, 403 or 500
|
||||
And the route /schema/new is declared before /schema/:key, has meta auth "verified" and title "Schema", and renders the builder
|
||||
And a route meta flag, not a path pattern, tells the shell that /schema/new needs the custom list only
|
||||
And only owners and admins can use Schema; the SPA decides from the session role, the API still enforces it
|
||||
And the six field types are text "Text", richtext "Rich text", number "Number", boolean "Yes or no", date "Date" and select "Choice"
|
||||
And the limits are a type name of up to 100 characters, a type key of 2 to 40, 1 to 50 fields, a field key of up to 40 characters and 1 to 50 choices
|
||||
And the layout is: Name and Key with the status chip, Discard and Save on top; the FieldList on the left; the FieldInspector on the right; the ApiPreview at the bottom
|
||||
And below 48rem the FieldList is the page, the FieldInspector is a 92% bottom sheet closed with "Done", the ApiPreview is a bottom sheet, and Discard and Save sit in a bottom bar
|
||||
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: Reaching the builder
|
||||
Given the Schema list is Populated or Empty
|
||||
When the user activates "New content type" (header button, empty-state button or the mobile floating button)
|
||||
Then the router navigates to /schema/new
|
||||
And the builder is shown, not the "Content type not found" page
|
||||
And /schema/events and /schema/unknown still show "Content type not found" for a key that is not built-in
|
||||
|
||||
Scenario: Roles and sessions
|
||||
Given <who> opens /schema/new
|
||||
Then <result>
|
||||
|
||||
Examples:
|
||||
| who | result |
|
||||
| a verified owner or admin | the builder |
|
||||
| a verified user who is neither | "You don't have access to Schema" with "Go to Home", URL unchanged |
|
||||
| an unverified user | the redirect to /register/pending |
|
||||
| a signed-out visitor | the redirect to /login |
|
||||
|
||||
Scenario: The builder needs no built-in list
|
||||
Given an owner opens /schema/new as the first page of the session
|
||||
Then GET /v1/content-type is called once for the sidebar
|
||||
And GET /v1/builtin-type is never called
|
||||
And the builder is shown as soon as the session is ready, with no skeleton
|
||||
|
||||
Scenario: New type, desktop (design state 1)
|
||||
Given an owner opens /schema/new on a 1440 px viewport
|
||||
Then the breadcrumb is "Schema" (a link to /schema) then "New content type"
|
||||
And "Name" is an empty text input with the placeholder "For example, Events", marked required, with maxlength 100
|
||||
And "Key" is a read-only mono input with the placeholder "events" and the help "Used in URLs and the API. Lowercase letters, digits and hyphens. It can't change after you save."
|
||||
And the list header reads "Fields" and "1 of 50"
|
||||
And one row is shown with the label "Title" and the key "title" in mono, selected, with the badge "New"
|
||||
And "Add field" is shown under the list
|
||||
And the inspector title is "Field: Title" with the badge "New"
|
||||
And "Label" holds "Title"
|
||||
And "Key" holds "title" with the Auto badge and the help "The name used in the API. Starts with a letter. Lowercase letters, digits and underscores."
|
||||
And the type tiles show Text selected
|
||||
And "Required" is off with "An entry can't be saved with this field empty."
|
||||
And "Unique values" is off
|
||||
And "Default value" is empty with "Used when an entry is created and this field is left out."
|
||||
And "Shortest" and "Longest" are empty with "Counted in characters. Entries show a counter."
|
||||
And "Save" and "Discard" are disabled
|
||||
And no status chip is shown
|
||||
And no "entries use this content type" bar is shown
|
||||
And the API preview bar is collapsed
|
||||
|
||||
Scenario: New type, mobile (design state 1)
|
||||
Given the same page on a 390 px viewport
|
||||
Then the topbar shows an arrow-left and the title "New content type"
|
||||
And Name and Key are stacked above the list, then the rows, then the "API preview" row with a chevron
|
||||
And no inspector is shown until a row is activated
|
||||
And the bottom bar holds Discard and Save
|
||||
|
||||
Scenario Outline: The key follows the name until it is edited
|
||||
Given an empty new type
|
||||
When the user types the name "<name>"
|
||||
Then the Key holds "<key>" with the Auto badge, read-only
|
||||
|
||||
Examples:
|
||||
| name | key |
|
||||
| Events | events |
|
||||
| Press mentions | press-mentions |
|
||||
| Café & Bar | cafe-bar |
|
||||
| Job openings! | job-openings |
|
||||
| 2026 Awards | 2026-awards |
|
||||
| !!! | (empty, mode empty) |
|
||||
| a name of 60 letters | the first 40 characters, without a trailing hyphen |
|
||||
|
||||
Scenario: Editing and resetting the key
|
||||
Given the name "Events" and the Auto key "events"
|
||||
When the user activates the pencil
|
||||
Then the Key becomes editable, the Auto badge goes and "Reset to match the name" is shown
|
||||
When the user types the key "agenda" then edits the name to "Events and talks"
|
||||
Then the Key stays "agenda"
|
||||
When the user activates "Reset to match the name"
|
||||
Then the Key is "events-and-talks" with the Auto badge
|
||||
|
||||
Scenario Outline: Type-level rules (design states 6 and 14)
|
||||
Given a new type with a valid field
|
||||
When the user sets <input> to <value> and activates Save
|
||||
Then <message> is shown under <input> with an alert icon
|
||||
And POST /v1/content-type is not called
|
||||
|
||||
Examples:
|
||||
| input | value | message |
|
||||
| Name | (empty or spaces) | Give the content type a name. |
|
||||
| Key | e | Use 2 to 40 lowercase letters, digits or hyphens. |
|
||||
| Key | Events | Use 2 to 40 lowercase letters, digits or hyphens. |
|
||||
| Key | my_type | Use 2 to 40 lowercase letters, digits or hyphens. |
|
||||
| Key | -events | Use 2 to 40 lowercase letters, digits or hyphens. |
|
||||
| Key | events--2 | Use 2 to 40 lowercase letters, digits or hyphens. |
|
||||
| Key | new | The key "new" is reserved. Choose a different key. (proposed copy) |
|
||||
|
||||
Scenario Outline: Accepted type keys
|
||||
Given a new type with a valid field and the name "X"
|
||||
When the user types the key "<key>" and activates Save
|
||||
Then POST /v1/content-type is called with that key
|
||||
|
||||
Examples:
|
||||
| key |
|
||||
| ab |
|
||||
| press-mentions |
|
||||
| 2026-awards |
|
||||
| 40 lowercase letters |
|
||||
|
||||
Scenario: Type-level errors together (design state 6)
|
||||
Given a new type with no name, the key "e" and no field
|
||||
When the user activates Save
|
||||
Then "Give the content type a name.", "Use 2 to 40 lowercase letters, digits or hyphens." and "A content type needs at least one field." are shown
|
||||
And the empty list shows "No fields yet" with the message in the error colour
|
||||
And the chip reads "3 problems to fix"
|
||||
And Save is disabled until all three are fixed, and Discard stays enabled
|
||||
And the inspector shows "Field settings" and "Add a field, then choose its type and rules here."
|
||||
|
||||
Scenario: When errors are shown
|
||||
Given a new type whose name is empty
|
||||
Then no error is shown while nothing was edited
|
||||
When the user edits an input, an error for that input shows as they type
|
||||
And when the user activates Save, every error of the form shows at once
|
||||
And errors are tied to their input with aria-describedby and aria-invalid
|
||||
And a row with an error shows an alert icon, not only the red border
|
||||
|
||||
Scenario Outline: The status chip
|
||||
Given <situation>
|
||||
Then the chip reads "<chip>"
|
||||
|
||||
Examples:
|
||||
| situation | chip |
|
||||
| a pristine new type | (no chip) |
|
||||
| an edit with no error | Unsaved changes |
|
||||
| three inputs in error | 3 problems to fix |
|
||||
| one input in error | 1 problem to fix |
|
||||
| the POST in flight | Saving… |
|
||||
| the POST failed with 500 | Save failed |
|
||||
| navigator.onLine is false | Waiting for connection |
|
||||
|
||||
Scenario Outline: Counting problems
|
||||
Given <errors>
|
||||
Then the chip counts <count>
|
||||
|
||||
Examples:
|
||||
| errors | count |
|
||||
| no name, a one-letter key, no field | 3 |
|
||||
| a field with no label, a field with min above max, a choice list with a blank and a duplicate | 3 |
|
||||
| one choice list with a blank row and a duplicate row | 1 |
|
||||
| one field with a bad key | 1 |
|
||||
|
||||
Scenario: Field list rows
|
||||
Given a type with the fields Title, Starts on, Online
|
||||
Then each row shows a grip button labelled "Move <label>", the icon box of its type, the label and the key in mono
|
||||
And an unselected row shows its badges "New", "Required", "Unique" and an alert icon when in error
|
||||
And the selected row is on the sand background and shows Move up, Move down and Remove
|
||||
And a field without a label reads "Untitled field" and a field without a key reads "–"
|
||||
|
||||
Scenario: Adding a field
|
||||
Given the list has 1 field
|
||||
When the user activates "Add field"
|
||||
Then a row is appended, selected, with a Text type and no label, and the label input is focused
|
||||
And the header reads "2 of 50"
|
||||
And at 50 fields "Add field" is disabled and the header reads "50 of 50"
|
||||
|
||||
Scenario Outline: Field key from the label
|
||||
Given a new field is selected
|
||||
When the user types the label "<label>"
|
||||
Then its Key holds "<key>" with the Auto badge
|
||||
|
||||
Examples:
|
||||
| label | key |
|
||||
| Title | title |
|
||||
| Starts on | starts_on |
|
||||
| Éléments | elements |
|
||||
| Ends on (date) | ends_on_date |
|
||||
| 2nd room | nd_room |
|
||||
| Title (when "title" exists)| title_2 |
|
||||
| a label of 60 letters | the first 40 characters |
|
||||
|
||||
Scenario Outline: Field key rules (design state 7)
|
||||
Given a new field is selected with the Key in manual mode
|
||||
When the user types the key "<key>"
|
||||
Then <outcome>
|
||||
|
||||
Examples:
|
||||
| key | outcome |
|
||||
| Title | "Start with a letter. Use lowercase letters, digits and underscores." under Key |
|
||||
| 1st | the same message |
|
||||
| a-b | the same message |
|
||||
| a b | the same message |
|
||||
| (empty) | the same message |
|
||||
| starts_on | no error |
|
||||
| a | no error |
|
||||
| a_40_characters | no error, and a 41st character cannot be typed |
|
||||
|
||||
Scenario: Duplicate field keys
|
||||
Given two fields whose keys are both "title" in manual mode
|
||||
Then "Another field already uses this key." (proposed copy) is shown under the Key of the second field
|
||||
And the chip counts one problem for that field
|
||||
|
||||
Scenario: A single field key error (design state 7)
|
||||
Given a new field with the key "start-date" and everything else valid
|
||||
Then the Key shows the error with the alert icon and the chip reads "1 problem to fix"
|
||||
And Save is disabled
|
||||
|
||||
Scenario Outline: The inspector follows the type
|
||||
Given a new field of type "<type>"
|
||||
Then the limits are labelled <limits>
|
||||
And "Unique values" is <unique>
|
||||
And the default control is <default>
|
||||
|
||||
Examples:
|
||||
| type | limits | unique | default |
|
||||
| text | "Shortest" and "Longest" with the suffix "characters" | available | a text input, placeholder "No default" |
|
||||
| richtext | "Shortest" and "Longest" with the suffix "characters" | disabled with "Not available for rich text." | a textarea, placeholder "No default" |
|
||||
| number | "Lowest value" and "Highest value" | available | a number input, placeholder "No default" |
|
||||
| boolean | none; "Yes or no fields have no limits." | disabled with "Not available for yes or no fields." | the switch "On by default" with help "New entries start with this switch off when the field is left out." |
|
||||
| date | "Earliest date" and "Latest date" | available | a date input, placeholder "No default" |
|
||||
| select | none; "Limits come from the choices below." | available | a select of the choices, placeholder "None" |
|
||||
|
||||
Scenario: Type tiles
|
||||
Given the inspector of a new field
|
||||
Then six radio tiles are shown, each with its icon, name and description
|
||||
| tile | icon | description |
|
||||
| Text | type | Short text, like a title or a name |
|
||||
| Rich text | pilcrow | Long text with formatting, written in Markdown|
|
||||
| Number | hash | Whole or decimal numbers |
|
||||
| Yes or no | toggle-left | An on or off switch |
|
||||
| Date | calendar | A calendar day, without a time |
|
||||
| Choice | list | One option from a list you define |
|
||||
And the selected tile is on the sand background
|
||||
And the help under "Longest" reads "Counted in characters. Entries show a counter." for Text and Rich text, and "Entries outside the range are refused." for Number and Date
|
||||
|
||||
Scenario: Changing the type of a new field
|
||||
Given a Number field with min 1, max 5000, default 20 and unique on
|
||||
When the user picks the tile "Yes or no"
|
||||
Then the label, the key and Required are kept
|
||||
And min, max, default, options and unique are reset
|
||||
When the user picks "Choice"
|
||||
Then the choice list editor is shown with no choice
|
||||
|
||||
Scenario Outline: Field rules
|
||||
Given a new field of type "<type>" with <settings>
|
||||
Then <message> is shown under <where>
|
||||
|
||||
Examples:
|
||||
| type | settings | message | where |
|
||||
| text | an empty label or spaces | Give the field a label. | Label |
|
||||
| number | min 6000, max 5000 | Lowest value can't be above the highest value. | the limits |
|
||||
| text | min 10, max 3 | Shortest can't be longer than longest. (proposed copy) | the limits |
|
||||
| date | min 2027-12-31, max 2026-01-01 | Earliest date can't be after the latest date. (proposed copy) | the limits |
|
||||
| text | min 2.5 | Use a whole number, 0 or more. (proposed copy) | the limit |
|
||||
| text | min -1 | Use a whole number, 0 or more. (proposed copy) | the limit |
|
||||
| number | min 1, max 5000, default 0 | The default value is outside the limits. (proposed copy) | Default value |
|
||||
| text | max 3, default "abcd" | The default value is outside the limits. (proposed copy) | Default value |
|
||||
| select | no choice | Add at least one choice. (proposed copy) | the choice list|
|
||||
| select | a blank choice | Choices can't be blank. | that row |
|
||||
| select | "Talk" twice | Two choices are the same. | the second row |
|
||||
| number | min 1, max 5000 | no error | - |
|
||||
| number | min 5, max 5 | no error | - |
|
||||
| text | min 0 | no error | - |
|
||||
|
||||
Scenario: Unique and default
|
||||
Given a Text field with the default "Draft"
|
||||
When the user turns "Unique values" on
|
||||
Then the Default value input is disabled with "A field with unique values can't have a default."
|
||||
And the default is not sent
|
||||
When the user turns "Unique values" off
|
||||
Then the default "Draft" is back
|
||||
|
||||
Scenario: Choice list editor (design state 8)
|
||||
Given a Choice field with the choices Talk, Workshop, Meetup
|
||||
Then "Choices" is shown with the required marker and "3 of 50"
|
||||
And each choice is a row with a grip, a text input, Remove, and the placeholder "Choice"
|
||||
And an input "Add a choice" with the button "Add" is shown
|
||||
And the help reads "Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save."
|
||||
When the user types "Panel" in "Add a choice" and activates Add (or presses Enter)
|
||||
Then the choice is appended and the input is emptied and keeps the focus
|
||||
And the Default value select lists the choices
|
||||
When the selected default choice is removed or renamed
|
||||
Then the default is cleared
|
||||
|
||||
Scenario: Choice list limits and errors
|
||||
Given a Choice field with 5 choices, the fourth blank and the fifth equal to the first
|
||||
Then "Choices can't be blank." is shown under the fourth row and "Two choices are the same." under the fifth, each with an alert icon
|
||||
And the field counts as one problem
|
||||
And at 50 choices the Add button is disabled
|
||||
And choices are compared after trimming, case-sensitively
|
||||
|
||||
Scenario: Reordering choices
|
||||
Given a Choice field with 3 choices
|
||||
When the user uses the grip of the second choice
|
||||
Then Move up and Move down work as for fields, with the same live region ("Moving Workshop. Position 2 of 3. ...")
|
||||
|
||||
Scenario: Removing a field
|
||||
Given a list with 3 fields
|
||||
When the user activates Remove on the selected one
|
||||
Then it disappears with no undo row (an undo row only exists for saved fields, slice 4)
|
||||
And the next row is selected, or the previous one at the end, or none if the list is empty
|
||||
And the header counts one less
|
||||
|
||||
Scenario Outline: Moving fields with the buttons
|
||||
Given a list Title, Starts on, Ends on with <selected> selected
|
||||
When the user activates <button>
|
||||
Then the order becomes <order>
|
||||
And the selected row stays selected and keeps the focus on the same button when possible
|
||||
|
||||
Examples:
|
||||
| selected | button | order |
|
||||
| Starts on | Move up | Starts on, Title, Ends on |
|
||||
| Starts on | Move down | Title, Ends on, Starts on |
|
||||
|
||||
Scenario Outline: Disabled ends
|
||||
Given a list of 3 fields with <row> selected
|
||||
Then <button> is disabled
|
||||
|
||||
Examples:
|
||||
| row | button |
|
||||
| Title | Move up |
|
||||
| Ends on | Move down |
|
||||
|
||||
Scenario: Keyboard reorder (design state 9)
|
||||
Given the list Title, Starts on, Ends on
|
||||
When the user focuses the grip "Move Starts on" and presses Space
|
||||
Then the row is picked up with the primary outline
|
||||
And the polite live region reads "Moving Starts on. Position 2 of 3. Press Up or Down to move, Space to drop, Esc to cancel."
|
||||
When the user presses Up
|
||||
Then the row is first and the live region reads "Moving Starts on. Position 1 of 3. ..."
|
||||
When the user presses Space
|
||||
Then the new order is kept and the live region announces the drop
|
||||
When the user picks it up again, moves it and presses Esc
|
||||
Then the order returns to what it was before the pick-up
|
||||
|
||||
Scenario: No pointer drag in this slice
|
||||
Then rows cannot be dragged with the pointer
|
||||
|
||||
Scenario: Selecting a field on mobile
|
||||
Given a 390 px viewport
|
||||
When the user activates a row
|
||||
Then a bottom sheet titled "Field: <label>" opens with the inspector, Move up, Move down and Remove (proposed placement), and a "Done" button
|
||||
And focus moves to the sheet title
|
||||
When the user activates Done
|
||||
Then the sheet closes and the focus returns to the row
|
||||
|
||||
Scenario: API preview (design state 10)
|
||||
Given the type key "events" and the fields Title, Starts on, Online, Capacity, Category
|
||||
When the user opens "API preview"
|
||||
Then the panel shows "Where entries of this type live" with these five routes
|
||||
| GET | /v1/content-type/events/entry |
|
||||
| POST | /v1/content-type/events/entry |
|
||||
| GET | /v1/content-type/events/entry/{id} |
|
||||
| PUT | /v1/content-type/events/entry/{id} |
|
||||
| DELETE | /v1/content-type/events/entry/{id} |
|
||||
And "A sample entry. Values come from each field's type and default." with the JSON of a sample entry
|
||||
And "Updates as you edit. Fields you add or remove here appear after you save."
|
||||
And the collapsed bar shows "API preview", "GET /v1/content-type/events/entry" in mono and "Copy JSON"
|
||||
And the key falls back to "events" while the Key is empty
|
||||
|
||||
Scenario Outline: Sample values in the preview
|
||||
Given a field "f" of type "<type>" with <settings>
|
||||
Then the sample entry data holds <value> for "f"
|
||||
|
||||
Examples:
|
||||
| type | settings | value |
|
||||
| text | no default | "Example text" |
|
||||
| text | default "Draft" | "Draft" |
|
||||
| richtext | no default | "Markdown text" |
|
||||
| number | no default, no min | 0 |
|
||||
| number | min 1 | 1 |
|
||||
| number | min 1, default 20 | 20 |
|
||||
| boolean | no default | false |
|
||||
| boolean | on by default | true |
|
||||
| date | no default | "2026-11-21" |
|
||||
| select | choices Talk, Workshop | "Talk" |
|
||||
| select | default "Workshop" | "Workshop" |
|
||||
| select | no choice yet | null |
|
||||
|
||||
Scenario: The sample entry envelope and live updates
|
||||
Then the sample is {"id": 42, "content_type": <key>, "data": {...}, "created_by": 3, "updated_by": 3, "created_at": "2026-10-15T11:02:00Z", "updated_at": "2026-10-15T11:02:00Z"} indented by 2 spaces
|
||||
And the data keys follow the field order and skip fields without a valid key
|
||||
And it updates on every edit, reorder and removal
|
||||
And it contains no date from the clock
|
||||
|
||||
Scenario: Copy JSON
|
||||
When the user activates "Copy JSON"
|
||||
Then the sample entry JSON is written to the clipboard
|
||||
And the button reads "Copied" for 2 seconds, then "Copy JSON" again
|
||||
And the same happens in the mobile sheet
|
||||
|
||||
Scenario: API preview on mobile
|
||||
Given a 390 px viewport
|
||||
When the user activates the row "API preview"
|
||||
Then a bottom sheet titled "API preview" shows "GET /v1/content-type/events/entry", the JSON and a full-width "Copy JSON" button
|
||||
|
||||
Scenario: Saving a valid type
|
||||
Given the name "Events", the key "events" and these fields
|
||||
| label | key | type | required | unique | default | min | max | options |
|
||||
| Title | title | text | true | true | | | 80 | |
|
||||
| Starts on | starts_on | date | true | false | | 2026-01-01 | 2027-12-31 | |
|
||||
| Online | online | boolean | false | false | on | | | |
|
||||
| Capacity | capacity | number | false | false | 20 | 1 | 5000 | |
|
||||
| Category | category | select | false | false | Talk | | | Talk, Workshop, Meetup |
|
||||
| Description | description | richtext | false | false | | | 5000 | |
|
||||
When the user activates Save
|
||||
Then POST /v1/content-type is called once with the key "events", the name "Events" and the fields in list order
|
||||
And each field carries exactly key, label, type, required, unique, default, min, max, options
|
||||
And unset default, min and max are null, and options are [] for a non-select field
|
||||
And the boolean default is true, an off switch sends null, a date bound is "YYYY-MM-DD", the name is trimmed, choices are trimmed
|
||||
And the body has no "icon" key
|
||||
|
||||
Scenario: Saving (design state 11, create)
|
||||
Given the POST has not answered
|
||||
Then the chip reads "Saving…" with a loader, Save shows a loader and is disabled, Discard is disabled
|
||||
And Name, Key and the inspector are dimmed and not editable
|
||||
And Add field, the grips, the Move and Remove buttons are disabled
|
||||
And a second activation of Save sends no second request
|
||||
|
||||
Scenario: Saved (design state 12, create)
|
||||
Given the POST answers 201 with the content type "events" named "Events" and 6 fields
|
||||
Then a success toast "Content type saved" with "Events now has 6 fields." is shown
|
||||
And the content types store holds "events" in name order when its list was loaded
|
||||
And the sidebar Content group lists "Events"
|
||||
And the router replaces the URL with /schema, where the Events row is listed
|
||||
And no "Leave without saving?" prompt is shown
|
||||
And with one field the toast reads "Events now has 1 field." (proposed copy)
|
||||
|
||||
Scenario: Key already taken (design state 14)
|
||||
Given the name "Events", the key "events" and the fields Title (required) and Starts on (date)
|
||||
When the POST answers 409 with the body "content_type_key_taken"
|
||||
Then under Key: The key "events" is already used by another content type. Choose a different key.
|
||||
And the Key is editable (manual mode), has the error and the focus, and the Auto badge is hidden
|
||||
And the chip reads "1 problem to fix" and Save is disabled
|
||||
And the fields and the name are untouched
|
||||
When the user edits the key
|
||||
Then the error goes and Save is enabled
|
||||
|
||||
Scenario: The server refuses a key or a field
|
||||
Given the POST answers <status> with <body>
|
||||
Then <outcome>
|
||||
|
||||
Examples:
|
||||
| status | body | outcome |
|
||||
| 400 | invalid_content_type_key | the key message under Key, as in the rules above |
|
||||
| 422 | invalid_field | the banner "Check the fields marked in the list." (proposed title and placement), edits kept |
|
||||
| 403 | Only ... | the page "You don't have access to Schema" |
|
||||
| 401 | (any) | the existing redirect to /login with the expired reason |
|
||||
|
||||
Scenario Outline: Save failed (design state 13)
|
||||
Given the POST <failure>
|
||||
Then an error alert titled "Couldn't save the content type" shows "Something went wrong on our side. Your changes are still here. Try again in a moment."
|
||||
And it has a "Try again" action and the chip reads "Save failed"
|
||||
And every edit is kept and Save stays enabled
|
||||
When the user activates "Try again"
|
||||
Then the same body is sent again
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| answers 500 |
|
||||
| fails (network) |
|
||||
|
||||
Scenario: Offline (design state 16)
|
||||
Given the user has edited the form and navigator.onLine becomes false
|
||||
Then the banner "You're offline. Changes will be saved when you reconnect." with a wifi-off icon is shown
|
||||
And the chip reads "Waiting for connection"
|
||||
And Save is disabled and every edit is kept
|
||||
When the connection returns
|
||||
Then the banner goes, Save is enabled again and nothing is sent by itself
|
||||
|
||||
Scenario: Discard
|
||||
Given the user has edited the form
|
||||
When the user activates Discard
|
||||
Then Name and Key are empty, the list holds the starter field Title, errors and the chip are cleared
|
||||
And nothing is sent and the URL stays /schema/new
|
||||
And Discard and Save are disabled again
|
||||
|
||||
Scenario Outline: Leaving with unsaved edits
|
||||
Given <state>
|
||||
When the user leaves through the breadcrumb, the mobile arrow, the sidebar or the browser back
|
||||
Then <result>
|
||||
|
||||
Examples:
|
||||
| state | result |
|
||||
| a pristine form | the router navigates with no prompt |
|
||||
| an edited name, key or field | the dialog "Leave without saving?" with "Keep editing" and "Discard changes" is shown |
|
||||
| after Save answered 201 | the router navigates with no prompt |
|
||||
|
||||
Scenario: Keep editing or discard on leave
|
||||
Given the leave dialog is open
|
||||
When the user chooses "Keep editing" (or dismisses the dialog)
|
||||
Then the user stays on /schema/new with every edit
|
||||
When the user chooses "Discard changes"
|
||||
Then the router navigates away
|
||||
|
||||
Scenario: Accessibility of the builder
|
||||
Then every control has a visible label
|
||||
And the type tiles are a radio group
|
||||
And the field list is one tab stop and arrow keys move between rows
|
||||
And controls are 44 px high on mobile
|
||||
And focus is a 2 px ring in the primary colour
|
||||
@@ -0,0 +1,82 @@
|
||||
# QA: Schema builder, create flow (SPA slice 3)
|
||||
|
||||
Runner: Vitest + Vue Test Utils (`task test:web`). No Go test: the POST /v1/content-type contract is already covered by features/custom_content_types.qa.md and nothing changes in the backend.
|
||||
Same pattern as features/schema_builtin_view.qa.md: mock `/v1` through the api composable (extend `views/__tests__/schemaTestKit.ts` with a POST handler and a configurable responder), mount with the real router and a resolved session whose role varies, use `components/__tests__/viewport.ts` for desktop (1440) and mobile (390), fake timers for the 2 s "Copied" and any delay. Never freeze the system date. Tests that touch the clipboard stub `navigator.clipboard.writeText`. Online/offline is driven by dispatching the window `offline` and `online` events.
|
||||
|
||||
## Automated
|
||||
|
||||
Fixtures: the three custom types and five built-in types of schemaTestKit; the "Events" form of the feature (6 fields) as the full-save case; a 201 response for it with id 4 and updated_at; the plain-text bodies `content_type_key_taken`, `invalid_content_type_key`, `invalid_field`.
|
||||
|
||||
Pure logic (suggested files lib/__tests__/schemaBuilder*.spec.ts; each rule table is an it.each over the feature's Examples):
|
||||
1. Type key derivation: the 7 name rows; empty result keeps the key empty.
|
||||
2. Field key derivation: the 7 label rows, including the `_2` suffix against an existing key and the 40-character cut.
|
||||
3. Type validation: name, key (the 7 rejected rows, the 4 accepted rows including 40 characters), fields count 0 / 1 / 50 / 51, reserved key "new".
|
||||
4. Field validation: label, key (8 rows), duplicate keys, limits ordering per type, whole non-negative limits for text, default against limits and length, choice rules (blank, duplicate after trim, none, 50 max), unique disabled for richtext and boolean, default dropped when unique. Assert each message string.
|
||||
5. Problem counting: the 4 rows of the feature (per field and per type-level input, never per message).
|
||||
6. Payload builder: the 6-field table gives the exact JSON, with null for unset values, [] options, boolean off to null, trimmed name and choices, ISO date bounds, no "icon", no unique default.
|
||||
7. Sample entry: the 12 value rows, the envelope with fixed ids and timestamps, key fallback "events", field order, skipped invalid keys, 2-space indentation.
|
||||
8. Type change reset (Number to Yes or no to Choice).
|
||||
|
||||
Store (extend stores/__tests__/contentTypes.spec.ts):
|
||||
9. add(type) inserts by name order when status is "ready", does nothing when "idle" (the next load returns it), does not touch built-in items, and does not trigger a request.
|
||||
|
||||
Composable and API (extend composables/__tests__/useContentTypes.spec.ts):
|
||||
10. createContentType posts JSON to /v1/content-type and returns the type; ApiError carries status and plain-text body for 400, 409, 422, 403, 500; NetworkError passes through.
|
||||
|
||||
Builder view (new views/__tests__/SchemaBuilderView.spec.ts and component specs for FieldList, FieldInspector, ChoiceList, ApiPreview), one test per feature scenario:
|
||||
11. Reaching the builder from the list button, the empty-state button and the mobile floating button; /schema/events and /schema/unknown still show "Content type not found".
|
||||
12. Roles: owner, admin, a role that is neither (403 page, no field shown), unverified and signed-out redirects (extend routerGuard.spec). Route registered before /schema/:key with the right meta, and GET /v1/builtin-type never called on /schema/new (assert the calls list).
|
||||
13. State 1 desktop: breadcrumb, Name and Key copy, "1 of 50", starter row, inspector copy, Save and Discard disabled, no chip, collapsed API bar, no "entries use" bar.
|
||||
14. State 1 mobile: topbar arrow-left and title, stacked Name and Key, "API preview" row, bottom bar, no inspector.
|
||||
15. Auto key and manual key: pencil, Auto badge, reset, key stays after a name edit.
|
||||
16. Type-level errors (it.each of the 7 rows) and state 6 together: the three messages, "No fields yet", "3 problems to fix", Save disabled then enabled after fixes, Discard enabled.
|
||||
17. When errors appear: none on a pristine form; per input while typing; all on Save; aria-describedby and aria-invalid wiring; alert icon on a row in error.
|
||||
18. Chip table (7 rows).
|
||||
19. Field list: row content, badges, selected row buttons, "Untitled field" and "–".
|
||||
20. Add field: appended, selected, label focused, counter, disabled at 50.
|
||||
21. Inspector per type (it.each over 6 types): labels, unique state with its message, default control, help texts, tiles with icons and descriptions; type change reset.
|
||||
22. Field rules (it.each over the Examples), unique and default interaction, key rules (state 7) and duplicate key.
|
||||
23. Choice list: add by button and Enter with focus kept, remove, reorder, default cleared on remove or rename, blank and duplicate flags, 50 cap, counter.
|
||||
24. Removing a field: selection moves, no undo row, empty list state.
|
||||
25. Move buttons and disabled ends (it.each); keyboard reorder: Space, Up, Down, Space and Esc, live region text at each step, order restored on Esc. Assert no drag handler on rows.
|
||||
26. Mobile sheets: row opens the field sheet with Done, focus to the title and back; API sheet with Copy JSON.
|
||||
27. API preview: routes, texts, sample JSON live update, key fallback, collapsed bar, Copy JSON writes the exact JSON and shows "Copied" for 2 s (fake timers).
|
||||
28. Save: the exact POST body, one request on double activation, the Saving state (dimmed, disabled controls, loader).
|
||||
29. Success: toast copy including the singular, store.add, sidebar item, router.replace('/schema'), no leave prompt, the list shows the new row without a second GET /v1/content-type.
|
||||
30. Key taken: 409 message, key editable with focus and no Auto badge, "1 problem to fix", Save disabled, enabled again after an edit.
|
||||
31. Server refusals: 400 invalid_content_type_key, 422 invalid_field banner, 403 page, 401 redirect.
|
||||
32. Save failed: 500 and network, banner copy, edits kept, "Try again" resends the same body.
|
||||
33. Offline: banner copy, chip, Save disabled, edits kept, recovery without any request.
|
||||
34. Discard resets to the blank form and sends nothing.
|
||||
35. Leave guard: pristine, edited, after save; Keep editing, dismiss and Discard changes.
|
||||
|
||||
Existing tests to update:
|
||||
36. features/schema_builtin_view.qa.md item 11 and SchemaTypeView.spec: remove /schema/new from the "Unknown key" it.each (it is now the builder).
|
||||
37. French: every new string has a French entry (the existing i18n completeness spec must pass).
|
||||
|
||||
## Visual (task visual:check -- schema)
|
||||
|
||||
Run `task visual:check -- schema`; every capture within 10 % at desktop and mobile, light and dark. Nothing in the covered states is clock-sensitive. Mock GET /v1/auth/me (owner "Marta Alves") and GET /v1/content-type (the 3 custom types) with the sidebar mock of the existing Schema states, plus every other /v1 call the page makes. The shell and sidebar are the board's `AppFrame`; if the board marks the sidebar differently from the SPA, report it, do not fix it silently.
|
||||
|
||||
Setups to add in `web/visual/states.ts` (screen `schema`):
|
||||
|
||||
| Board state | Setup | Sample data (from the frame) |
|
||||
|---|---|---|
|
||||
| New type | open /schema/new, no steps | empty name and key (placeholders "For example, Events" and "events"), starter field Title/title, Text, optional |
|
||||
| Type-level errors | open /schema/new; steps: edit the key and type "e", Remove the starter field, activate Save | empty name, key "e", no fields, chip "3 problems to fix" |
|
||||
| Key already taken | open /schema/new; mock POST /v1/content-type as 409 with the body `content_type_key_taken`; steps: type the name "Events", turn Required on for Title, Add field with the label "Starts on" and the Date tile (key starts_on), select the first row, activate Save | name "Events", key "events", fields Title (required) and Starts on (date), the key error, chip "1 problem to fix" |
|
||||
|
||||
Known visual differences to report, never hide: the missing `...` button (D15), the absent chip in the pristine state (Q2), and the Key help copy (Q1) if the board text is kept.
|
||||
|
||||
`skip: 'pending'` (drawn on the Events edit screen, which exists in slice 4): Editing an existing type, Locked key: tooltip, Unsaved changes, Field errors, Choice list editor, Keyboard reorder, API preview open, Saving, Saved, Save failed, Schema incompatible, Offline. Field key error (state 7) has no board frame: Vitest only. If check.ts demands an entry for it, add a pending skip, and report it.
|
||||
|
||||
The "Review and delete" group is slice 4/5: every state stays pending.
|
||||
|
||||
## Manual
|
||||
|
||||
- Keyboard: Tab order is Name, Key (pencil), Save, Discard, field list (one stop, arrows move between rows), inspector top to bottom, API preview. Screen reader (NVDA or VoiceOver): the live region reads each reorder step; the tile group announces its selected tile; errors are read with their input.
|
||||
- Focus ring on every control; hover and motion on rows, tiles, sheets and the Copy JSON button; sheet open and close focus return on a real phone.
|
||||
- Clipboard: Copy JSON pastes the exact sample entry in a real browser (permission prompt behaviour).
|
||||
- Real backend: as owner, then admin, create a type with all six field types including a Choice default and a unique Title. Check the type in `GET /v1/content-type`, in the sidebar Content group, and in the Schema list. Create the same key again for a real 409. Send an invalid body through curl to compare the 400 and 422 texts with the mapped messages.
|
||||
- Real offline: switch the browser offline, edit, reconnect, press Save.
|
||||
- Reload or close the tab with unsaved edits (not handled by the leave guard): note the browser behaviour.
|
||||
@@ -126,7 +126,6 @@ Feature: Read-only view of a built-in content type
|
||||
| path | why |
|
||||
| /schema/unknown | a mistyped key |
|
||||
| /schema/events | a custom type; its builder is a later slice |
|
||||
| /schema/new | the builder is a later slice |
|
||||
|
||||
Scenario Outline: Error
|
||||
Given GET /v1/builtin-type <failure>
|
||||
|
||||
@@ -20,7 +20,7 @@ Read-only view (new SchemaBuiltinView.spec), one test per state:
|
||||
8. Back: the breadcrumb and the mobile arrow push /schema.
|
||||
9. Deep link: a store in its initial state loads the builtin list once on mount.
|
||||
10. Loading: unresolved promise, fake timers. Nothing at 149 ms, skeleton at 150 ms, no not-found or 403 page. A fast response never shows the skeleton.
|
||||
11. Unknown key: it.each over /schema/unknown, /schema/events, /schema/new. Assert the "Content type not found" page copy, URL unchanged, no table.
|
||||
11. Unknown key: it.each over /schema/unknown, /schema/events. Assert the "Content type not found" page copy, URL unchanged, no table.
|
||||
12. Error: 500 and network failure. Assert the alert copy ("GET /v1/builtin-type failed. ..."), "Try again", no table. Retry then returns 200 and the view replaces the alert (second call made).
|
||||
13. Forbidden: unknown role gives the 403 page copy, "Go to Home" navigates to /home, URL stays /schema/articles, builtin-type never called. Plus a case where builtin-type answers 403 for an owner.
|
||||
14. Shared load: navigating /schema -> /schema/articles -> back calls each list endpoint once.
|
||||
|
||||
@@ -398,3 +398,13 @@ body {
|
||||
var(--ui-bg-accented) 8px 16px
|
||||
);
|
||||
}
|
||||
|
||||
.no-spin {
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.no-spin::-webkit-inner-spin-button,
|
||||
.no-spin::-webkit-outer-spin-button {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useJsonCopy } from '@/composables/useJsonCopy'
|
||||
import { previewRoutes } from '@/lib/schemaBuilderSample'
|
||||
|
||||
const props = defineProps<{ keyValue: string; json: string }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { copied, copy, label: copyLabel, icon: copyIcon } = useJsonCopy()
|
||||
|
||||
const routes = computed(() => previewRoutes(props.keyValue))
|
||||
const summary = computed(() => `${routes.value[0]!.method} ${routes.value[0]!.path}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:aria-label="t('API preview')"
|
||||
class="flex shrink-0 flex-col"
|
||||
data-testid="api-preview"
|
||||
>
|
||||
<div
|
||||
class="flex h-11 items-center gap-2.5 border-t border-default bg-default px-3 text-highlighted md:px-6"
|
||||
>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:icon="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
|
||||
:aria-expanded="open"
|
||||
data-testid="api-preview-toggle"
|
||||
class="h-11 min-w-0 flex-1 justify-start gap-2.5 px-0 font-semibold hover:bg-transparent"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span>{{ t('API preview') }}</span>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-left font-mono text-xs font-normal text-muted"
|
||||
>{{ summary }}</span
|
||||
>
|
||||
</UButton>
|
||||
<UButton
|
||||
:icon="copyIcon"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
data-testid="api-copy"
|
||||
class="h-7 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0"
|
||||
:class="copied ? 'text-success' : ''"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
@click="copy(json)"
|
||||
>
|
||||
<span aria-live="polite">{{ copyLabel }}</span>
|
||||
</UButton>
|
||||
</div>
|
||||
<UCollapsible :open="open" :ui="{ content: 'overflow-hidden' }">
|
||||
<template #content>
|
||||
<div class="grid h-62 grid-cols-12 grid-rows-1 gap-4 bg-default px-6 pb-4">
|
||||
<div class="col-span-5 flex min-h-0 min-w-0 flex-col gap-1.5">
|
||||
<span class="text-xs text-muted">{{
|
||||
t('Where entries of this type live')
|
||||
}}</span>
|
||||
<ul
|
||||
class="m-0 flex list-none flex-col gap-0.5 border border-default bg-elevated px-3 py-2.5"
|
||||
>
|
||||
<li
|
||||
v-for="(route, index) in routes"
|
||||
:key="index"
|
||||
data-testid="api-route"
|
||||
class="truncate font-mono text-xs/5 text-highlighted"
|
||||
>
|
||||
<span class="inline-block w-14 text-muted">{{ route.method }}</span
|
||||
>{{ route.path }}
|
||||
</li>
|
||||
</ul>
|
||||
<span class="text-xs/4.5 text-pretty text-muted">{{
|
||||
t(
|
||||
'Updates as you edit. Fields you add or remove here appear after you save.',
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="col-span-7 flex min-h-0 min-w-0 flex-col gap-1.5">
|
||||
<span class="text-xs text-muted">{{
|
||||
t("A sample entry. Values come from each field's type and default.")
|
||||
}}</span>
|
||||
<pre
|
||||
tabindex="0"
|
||||
data-testid="api-json"
|
||||
class="m-0 min-h-0 flex-1 overflow-auto border border-default bg-elevated px-3 py-2.5 font-mono text-xs/5 whitespace-pre text-highlighted"
|
||||
>{{ json }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UCollapsible>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useJsonCopy } from '@/composables/useJsonCopy'
|
||||
import { previewRoutes } from '@/lib/schemaBuilderSample'
|
||||
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
|
||||
|
||||
const props = defineProps<{ keyValue: string; json: string }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { copy, label: copyLabel, icon: copyIcon } = useJsonCopy()
|
||||
|
||||
const first = computed(() => previewRoutes(props.keyValue)[0]!)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="t('API preview')"
|
||||
:description="t('Where entries of this type live')"
|
||||
:ui="BOTTOM_SHEET_UI"
|
||||
>
|
||||
<template #body>
|
||||
<span class="text-xs text-muted" data-testid="api-sheet-route">{{
|
||||
`${first.method} ${first.path}`
|
||||
}}</span>
|
||||
<pre
|
||||
tabindex="0"
|
||||
data-testid="api-sheet-json"
|
||||
class="m-0 min-h-0 flex-1 overflow-auto border border-default bg-elevated px-3 py-2.5 font-mono text-xs/5 whitespace-pre text-highlighted"
|
||||
>{{ json }}</pre
|
||||
>
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton
|
||||
:icon="copyIcon"
|
||||
data-testid="api-sheet-copy"
|
||||
class="h-11 flex-1 justify-center"
|
||||
@click="copy(json)"
|
||||
>
|
||||
<span aria-live="polite">{{ copyLabel }}</span>
|
||||
</UButton>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
@@ -5,7 +5,8 @@ import { computed, nextTick, ref, useId } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import type { Byline } from '@/composables/useArticles'
|
||||
import { insertionTarget, moveItem } from '@/lib/articleMeta'
|
||||
import { moveItem } from '@/lib/arrayMove'
|
||||
import { insertionTarget } from '@/lib/articleMeta'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 64rem)'
|
||||
const DESKTOP_AVATAR = 22
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import BuilderStatusChip from '@/components/BuilderStatusChip.vue'
|
||||
import type { BuilderStatus } from '@/lib/schemaBuilderModel'
|
||||
|
||||
defineProps<{
|
||||
status: BuilderStatus
|
||||
problems: number
|
||||
canSave: boolean
|
||||
canReveal: boolean
|
||||
canDiscard: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ save: []; discard: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex shrink-0 items-center gap-2 md:gap-3">
|
||||
<BuilderStatusChip :status="status" :problems="problems" />
|
||||
<span class="flex-1 md:hidden" />
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="!canDiscard"
|
||||
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
|
||||
@click="$emit('discard')"
|
||||
>
|
||||
{{ t('Discard') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
:disabled="!canSave && !canReveal"
|
||||
:aria-disabled="canReveal ? 'true' : undefined"
|
||||
:loading="saving"
|
||||
class="h-11 px-4 md:h-8"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
{{ saving ? t('Saving…') : t('Save') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ kind: 'new' | 'required' | 'unique'; onSand?: boolean }>()
|
||||
|
||||
const badges = {
|
||||
new: {
|
||||
get label() {
|
||||
return t('New')
|
||||
},
|
||||
class: 'border border-(--rc-sand) bg-(--rc-sand) text-(--rc-on-sand)',
|
||||
},
|
||||
required: {
|
||||
get label() {
|
||||
return t('Required')
|
||||
},
|
||||
class: 'border border-accented bg-transparent text-toned',
|
||||
},
|
||||
unique: {
|
||||
get label() {
|
||||
return t('Unique')
|
||||
},
|
||||
class: 'border border-accented bg-transparent text-toned',
|
||||
},
|
||||
} as const
|
||||
|
||||
const OUTLINE_ON_SAND = 'border border-(--rc-on-sand) bg-transparent text-(--rc-on-sand)'
|
||||
|
||||
const badge = computed(() => badges[props.kind])
|
||||
const badgeClass = computed(() =>
|
||||
props.onSand && props.kind !== 'new' ? OUTLINE_ON_SAND : badge.value.class,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:class="badgeClass"
|
||||
:ui="{ base: 'h-5 shrink-0 px-1.5 py-0 text-xs font-medium whitespace-nowrap' }"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</UBadge>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import FieldInspector from '@/components/FieldInspector.vue'
|
||||
import type { useSchemaBuilder } from '@/composables/useSchemaBuilder'
|
||||
import type { BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
|
||||
|
||||
type Builder = ReturnType<typeof useSchemaBuilder>
|
||||
|
||||
const props = defineProps<{
|
||||
builder: Builder
|
||||
field: BuilderField
|
||||
errors: FieldErrors
|
||||
compact: boolean
|
||||
}>()
|
||||
|
||||
const uid = () => props.field.uid
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FieldInspector
|
||||
:field="field"
|
||||
:errors="errors"
|
||||
:disabled="builder.saving.value"
|
||||
:compact="compact"
|
||||
:focus-token="builder.labelFocusToken.value"
|
||||
@label="builder.setLabel(uid(), $event)"
|
||||
@key="builder.setFieldKey(uid(), $event)"
|
||||
@unlock="builder.unlockFieldKey(uid())"
|
||||
@reset="builder.resetFieldKey(uid())"
|
||||
@type="builder.setFieldType(uid(), $event)"
|
||||
@required="builder.patchField(uid(), { required: $event })"
|
||||
@unique="builder.patchField(uid(), { unique: $event })"
|
||||
@limit="(which, value) => builder.setLimit(uid(), which, value)"
|
||||
@default="builder.setDefault(uid(), $event)"
|
||||
@default-on="builder.patchField(uid(), { defaultOn: $event })"
|
||||
@add-choice="builder.addChoice(uid(), $event)"
|
||||
@remove-choice="builder.removeChoice(uid(), $event)"
|
||||
@rename-choice="(index, text) => builder.renameChoice(uid(), index, text)"
|
||||
@reorder-choices="builder.reorderChoices(uid(), $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { BuilderStatus } from '@/lib/schemaBuilderModel'
|
||||
|
||||
const props = defineProps<{ status: BuilderStatus; problems: number }>()
|
||||
|
||||
const text = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'saving':
|
||||
return t('Saving…')
|
||||
case 'offline':
|
||||
return t('Waiting for connection')
|
||||
case 'failed':
|
||||
return t('Save failed')
|
||||
case 'problems':
|
||||
return props.problems === 1
|
||||
? t('1 problem to fix')
|
||||
: t('{0} problems to fix', [props.problems])
|
||||
default:
|
||||
return t('Unsaved changes')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="status !== 'none'"
|
||||
role="status"
|
||||
data-testid="builder-chip"
|
||||
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',
|
||||
}"
|
||||
>
|
||||
<UIcon
|
||||
v-if="status === 'saving'"
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-3.5 animate-spin"
|
||||
/>
|
||||
<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 name="i-lucide-circle-alert" class="size-3.5" />
|
||||
<span>{{ text }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import {
|
||||
BUILDER_LIMITS,
|
||||
PREVIEW_KEY_FALLBACK,
|
||||
keyDisplayMode,
|
||||
type KeyMode,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
keyValue: string
|
||||
keyMode: KeyMode
|
||||
nameError?: string
|
||||
keyError?: string
|
||||
disabled: boolean
|
||||
keyFocusToken: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:name': [value: string]
|
||||
'update:key': [value: string]
|
||||
unlock: []
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const slugMode = computed(() => keyDisplayMode(props.keyMode, props.keyValue))
|
||||
const hasError = computed(() => Boolean(props.nameError || props.keyError))
|
||||
|
||||
watch(
|
||||
() => props.keyFocusToken,
|
||||
async () => {
|
||||
await nextTick()
|
||||
root.value?.querySelector<HTMLInputElement>('input[name="key"]')?.focus()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="flex min-w-0 flex-col gap-3 transition-opacity md:flex-row md:gap-4"
|
||||
:class="[hasError ? 'md:items-start' : 'md:items-end', disabled ? 'opacity-60' : '']"
|
||||
>
|
||||
<div class="md:w-80">
|
||||
<DirectoryField :label="t('Name')" name="name" required :error="nameError">
|
||||
<UInput
|
||||
:model-value="name"
|
||||
:placeholder="t('For example, Events')"
|
||||
:maxlength="BUILDER_LIMITS.nameMax"
|
||||
:disabled="disabled"
|
||||
:color="nameError ? 'error' : undefined"
|
||||
required
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('update:name', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
</div>
|
||||
<div :class="keyError ? 'md:w-90' : 'md:w-80'">
|
||||
<SlugField
|
||||
:label="t('Key')"
|
||||
name="key"
|
||||
:mode="slugMode"
|
||||
:value="keyValue"
|
||||
:error="keyError"
|
||||
:disabled="disabled"
|
||||
dense
|
||||
reset-in-label
|
||||
:help="
|
||||
t(
|
||||
'Used in URLs and the API. Lowercase letters, digits and hyphens. It can\'t change after you save.',
|
||||
)
|
||||
"
|
||||
:edit-label="t('Edit key')"
|
||||
:placeholder="PREVIEW_KEY_FALLBACK"
|
||||
:maxlength="BUILDER_LIMITS.typeKeyMax"
|
||||
@update:value="emit('update:key', $event)"
|
||||
@unlock="emit('unlock')"
|
||||
@reset="emit('reset')"
|
||||
/>
|
||||
</div>
|
||||
<span class="hidden flex-1 md:block" />
|
||||
<div v-if="$slots.actions" :class="hasError ? 'md:mt-5.5' : ''">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useKeyboardReorder } from '@/composables/useKeyboardReorder'
|
||||
import { BUILDER_LIMITS } from '@/lib/schemaBuilderModel'
|
||||
|
||||
const props = defineProps<{
|
||||
options: string[]
|
||||
rowErrors?: Record<number, string>
|
||||
listError?: string
|
||||
disabled: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: [text: string]
|
||||
remove: [index: number]
|
||||
rename: [index: number, text: string]
|
||||
reorder: [options: string[]]
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const draft = ref('')
|
||||
const addInput = ref<{ inputRef?: HTMLInputElement | null } | null>(null)
|
||||
|
||||
interface ChoiceRow {
|
||||
index: number
|
||||
text: string
|
||||
}
|
||||
|
||||
const reorder = useKeyboardReorder<ChoiceRow>({
|
||||
items: () => props.options.map((text, index) => ({ index, text })),
|
||||
name: (row) => row.text.trim() || t('Choice'),
|
||||
commit: (next) =>
|
||||
emit(
|
||||
'reorder',
|
||||
next.map((row) => row.text),
|
||||
),
|
||||
root: () => root.value,
|
||||
})
|
||||
|
||||
const rows = computed(() => reorder.shown.value)
|
||||
const full = computed(() => props.options.length >= BUILDER_LIMITS.choicesMax)
|
||||
const countText = computed(() => t('{0} of {1}', [props.options.length, BUILDER_LIMITS.choicesMax]))
|
||||
const canAdd = computed(() => !props.disabled && !full.value && draft.value.trim() !== '')
|
||||
|
||||
async function add() {
|
||||
if (!canAdd.value) {
|
||||
return
|
||||
}
|
||||
emit('add', draft.value)
|
||||
draft.value = ''
|
||||
await nextTick()
|
||||
addInput.value?.inputRef?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="flex flex-col gap-2" data-testid="choice-list">
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium text-highlighted"
|
||||
>{{ t('Choices') }}<span aria-hidden="true"> *</span></span
|
||||
>
|
||||
<span class="font-mono text-xs text-muted" data-testid="choice-count">{{
|
||||
countText
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="choice-live"
|
||||
:class="
|
||||
reorder.active.value
|
||||
? 'bg-inverted px-2.5 py-2 text-xs/4.5 text-inverted'
|
||||
: 'sr-only'
|
||||
"
|
||||
>
|
||||
{{ reorder.announcement.value }}
|
||||
</div>
|
||||
|
||||
<ul class="m-0 flex list-none flex-col gap-2 p-0">
|
||||
<li
|
||||
v-for="(row, index) in rows"
|
||||
:key="row.index"
|
||||
data-testid="choice-row"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
:class="reorder.grabbed.value === index ? 'outline-2 outline-primary' : ''"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:data-handle="index"
|
||||
:aria-label="t('Move {0}', [row.text.trim() || t('Choice')])"
|
||||
:aria-pressed="reorder.grabbed.value === index"
|
||||
:disabled="disabled"
|
||||
class="flex size-7 shrink-0 items-center justify-center text-muted focus-visible:outline-2 focus-visible:outline-primary max-md:size-11"
|
||||
@keydown="reorder.onKeydown($event, index)"
|
||||
>
|
||||
<UIcon name="i-lucide-grip-vertical" class="size-4" />
|
||||
</button>
|
||||
<UInput
|
||||
:model-value="row.text"
|
||||
:placeholder="t('Choice')"
|
||||
:disabled="disabled"
|
||||
:color="rowErrors?.[row.index] ? 'error' : undefined"
|
||||
:aria-label="t('Choice {0}', [index + 1])"
|
||||
:aria-invalid="rowErrors?.[row.index] ? 'true' : undefined"
|
||||
:aria-describedby="
|
||||
rowErrors?.[row.index] ? `choice-error-${row.index}` : undefined
|
||||
"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('rename', row.index, String($event))"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-x"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('Remove {0}', [row.text.trim() || t('Choice')])"
|
||||
class="size-7 justify-center max-md:size-11"
|
||||
@click="emit('remove', row.index)"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-if="rowErrors?.[row.index]"
|
||||
:id="`choice-error-${row.index}`"
|
||||
role="alert"
|
||||
class="ml-8 flex items-start gap-1.5 text-xs/4.5 text-error max-md:ml-12"
|
||||
>
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{{ rowErrors[row.index] }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-if="listError" role="alert" class="flex items-start gap-1.5 text-xs/4.5 text-error">
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{{ listError }}</span>
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2 pl-8 max-md:pl-12">
|
||||
<UInput
|
||||
ref="addInput"
|
||||
v-model="draft"
|
||||
:placeholder="t('Add a choice')"
|
||||
:aria-label="t('Add a choice')"
|
||||
:disabled="disabled || full"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ base: 'h-11 border-dashed text-lg md:h-8 md:text-sm' }"
|
||||
@keydown.enter.prevent="add"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="!canAdd"
|
||||
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
|
||||
@click="add"
|
||||
>
|
||||
{{ t('Add') }}
|
||||
</UButton>
|
||||
</div>
|
||||
<p class="text-xs/4.5 text-pretty text-muted">
|
||||
{{
|
||||
t(
|
||||
'Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,6 +16,7 @@ import ErrorView from '@/components/ErrorView.vue'
|
||||
import OfflineBanner from '@/components/OfflineBanner.vue'
|
||||
import UpdatePill from '@/components/UpdatePill.vue'
|
||||
import UserMenu from '@/components/UserMenu.vue'
|
||||
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -67,7 +68,9 @@ const update = useUpdateStore()
|
||||
const api = useApi()
|
||||
const contentTypes = useContentTypesStore()
|
||||
|
||||
const wantsBuiltin = computed(() => session.canManageContent && /^\/schema(\/|$)/.test(route.path))
|
||||
const wantsBuiltin = computed(
|
||||
() => session.canManageContent && route.meta.needsBuiltinTypes === true,
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => session.status === 'ready' && session.session?.verified === true, wantsBuiltin],
|
||||
@@ -115,7 +118,7 @@ const schemaItem = computed(() =>
|
||||
get label() {
|
||||
return t('Schema')
|
||||
},
|
||||
to: '/schema',
|
||||
to: SCHEMA_PATH,
|
||||
icon: 'i-lucide-shapes',
|
||||
},
|
||||
]
|
||||
@@ -128,7 +131,7 @@ const navItems = computed(() =>
|
||||
get label() {
|
||||
return t('Home')
|
||||
},
|
||||
to: '/home',
|
||||
to: HOME_PATH,
|
||||
icon: 'i-lucide-home',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,11 +5,12 @@ withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
optional?: boolean
|
||||
required?: boolean
|
||||
error?: string
|
||||
help?: string
|
||||
name?: string
|
||||
}>(),
|
||||
{ label: '', optional: false, error: '', help: '', name: undefined },
|
||||
{ label: '', optional: false, required: false, error: '', help: '', name: undefined },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -29,7 +30,7 @@ withDefaults(
|
||||
}"
|
||||
>
|
||||
<template v-if="label" #label>
|
||||
{{ label }}
|
||||
{{ label }}<span v-if="required" aria-hidden="true"> *</span>
|
||||
<span v-if="optional" class="font-normal text-muted"> {{ t('(optional)') }}</span>
|
||||
</template>
|
||||
<template v-if="$slots.hint" #hint>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import RetryAlert from '@/components/RetryAlert.vue'
|
||||
|
||||
defineProps<{ title: string; description?: string }>()
|
||||
|
||||
@@ -7,28 +8,9 @@ defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAlert
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
<RetryAlert
|
||||
:title="title"
|
||||
:description="description ?? t('Check your connection and try again.')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<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="$emit('retry')"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
@retry="$emit('retry')"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{ first: boolean; last: boolean; disabled: boolean }>()
|
||||
|
||||
defineEmits<{ up: []; down: []; remove: [] }>()
|
||||
|
||||
const BUTTON_CLASS = 'size-7 justify-center max-md:size-11'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex shrink-0 gap-0.5">
|
||||
<UButton
|
||||
icon="i-lucide-chevron-up"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
data-action="up"
|
||||
:aria-label="t('Move up')"
|
||||
:disabled="disabled || first"
|
||||
:class="BUTTON_CLASS"
|
||||
@click.stop="$emit('up')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-chevron-down"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
data-action="down"
|
||||
:aria-label="t('Move down')"
|
||||
:disabled="disabled || last"
|
||||
:class="BUTTON_CLASS"
|
||||
@click.stop="$emit('down')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
data-action="remove"
|
||||
:aria-label="t('Remove')"
|
||||
:disabled="disabled"
|
||||
:class="BUTTON_CLASS"
|
||||
@click.stop="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import AppSwitch from '@/components/AppSwitch.vue'
|
||||
import ChoiceList from '@/components/ChoiceList.vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import FieldTypePicker from '@/components/FieldTypePicker.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import {
|
||||
BUILDER_LIMITS,
|
||||
STARTER_KEY,
|
||||
keyDisplayMode,
|
||||
supportsLimits,
|
||||
supportsUnique,
|
||||
trimmedOptions,
|
||||
type BuilderField,
|
||||
type FieldType,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
|
||||
|
||||
const NO_DEFAULT = 'none'
|
||||
const CHOICE_PREFIX = 'choice:'
|
||||
|
||||
const props = defineProps<{
|
||||
field: BuilderField
|
||||
errors: FieldErrors
|
||||
disabled: boolean
|
||||
compact: boolean
|
||||
focusToken: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
label: [value: string]
|
||||
key: [value: string]
|
||||
unlock: []
|
||||
reset: []
|
||||
type: [type: FieldType]
|
||||
required: [value: boolean]
|
||||
unique: [value: boolean]
|
||||
limit: [which: 'min' | 'max', value: string]
|
||||
default: [value: string]
|
||||
defaultOn: [value: boolean]
|
||||
addChoice: [text: string]
|
||||
removeChoice: [index: number]
|
||||
renameChoice: [index: number, text: string]
|
||||
reorderChoices: [options: string[]]
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const type = computed(() => props.field.type)
|
||||
const hasLimits = computed(() => supportsLimits(type.value))
|
||||
const canUnique = computed(() => supportsUnique(type.value))
|
||||
const keyMode = computed(() => keyDisplayMode(props.field.keyMode, props.field.key))
|
||||
const gridClass = computed(() => (props.compact ? 'grid-cols-1' : 'md:grid-cols-2'))
|
||||
|
||||
const limitLabels = computed(() => {
|
||||
if (type.value === 'number') {
|
||||
return [t('Lowest value'), t('Highest value')] as const
|
||||
}
|
||||
if (type.value === 'date') {
|
||||
return [t('Earliest date'), t('Latest date')] as const
|
||||
}
|
||||
return [t('Shortest'), t('Longest')] as const
|
||||
})
|
||||
const limitInput = computed(() => {
|
||||
if (type.value === 'date') {
|
||||
return 'date'
|
||||
}
|
||||
return 'number'
|
||||
})
|
||||
const isTextual = computed(() => type.value === 'text' || type.value === 'richtext')
|
||||
const limitSuffix = computed(() => (isTextual.value ? t('characters') : ''))
|
||||
const maxHelp = computed(() =>
|
||||
isTextual.value
|
||||
? t('Counted in characters. Entries show a counter.')
|
||||
: t('Entries outside the range are refused.'),
|
||||
)
|
||||
const noLimitsText = computed(() =>
|
||||
type.value === 'boolean'
|
||||
? t('Yes or no fields have no limits.')
|
||||
: t('Limits come from the choices below.'),
|
||||
)
|
||||
|
||||
const uniqueHelp = computed(() => {
|
||||
if (canUnique.value) {
|
||||
return t('Each entry needs a different value.')
|
||||
}
|
||||
return type.value === 'richtext'
|
||||
? t('Not available for rich text.')
|
||||
: t('Not available for yes or no fields.')
|
||||
})
|
||||
const defaultHelp = computed(() =>
|
||||
props.field.unique
|
||||
? t("A field with unique values can't have a default.")
|
||||
: t('Used when an entry is created and this field is left out.'),
|
||||
)
|
||||
const defaultOnHelp = computed(() =>
|
||||
props.field.defaultOn
|
||||
? t('New entries start with this switch on when the field is left out.')
|
||||
: t('New entries start with this switch off when the field is left out.'),
|
||||
)
|
||||
|
||||
const choiceItems = computed(() => [
|
||||
{ label: t('None'), value: NO_DEFAULT },
|
||||
...Array.from(new Set(trimmedOptions(props.field).filter((option) => option !== ''))).map(
|
||||
(option) => ({ label: option, value: `${CHOICE_PREFIX}${option}` }),
|
||||
),
|
||||
])
|
||||
const choiceValue = computed(() =>
|
||||
props.field.defaultValue === '' ? NO_DEFAULT : `${CHOICE_PREFIX}${props.field.defaultValue}`,
|
||||
)
|
||||
|
||||
function onChoice(value: unknown) {
|
||||
const text = String(value)
|
||||
emit('default', text === NO_DEFAULT ? '' : text.slice(CHOICE_PREFIX.length))
|
||||
}
|
||||
|
||||
const SWITCH_UI = {
|
||||
root: 'items-start gap-3',
|
||||
wrapper: 'ms-0',
|
||||
label: 'font-medium text-highlighted',
|
||||
description: 'mt-0 text-xs/4.5 text-pretty text-muted',
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.focusToken,
|
||||
async () => {
|
||||
await nextTick()
|
||||
root.value?.querySelector<HTMLInputElement>('input[name="label"]')?.focus()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="flex min-w-0 flex-col gap-3.5 transition-opacity max-md:gap-4"
|
||||
:class="disabled ? 'opacity-60' : ''"
|
||||
data-testid="field-inspector"
|
||||
>
|
||||
<div class="grid gap-3.5" :class="gridClass">
|
||||
<DirectoryField :label="t('Label')" name="label" required :error="errors.label">
|
||||
<UInput
|
||||
:model-value="field.label"
|
||||
:placeholder="t('For example, Title')"
|
||||
:disabled="disabled"
|
||||
:color="errors.label ? 'error' : undefined"
|
||||
required
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('label', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<SlugField
|
||||
:label="t('Key')"
|
||||
name="field-key"
|
||||
:mode="keyMode"
|
||||
:value="field.key"
|
||||
:error="errors.key"
|
||||
:disabled="disabled"
|
||||
dense
|
||||
:placeholder="STARTER_KEY"
|
||||
:maxlength="BUILDER_LIMITS.fieldKeyMax"
|
||||
:help="
|
||||
t(
|
||||
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
|
||||
)
|
||||
"
|
||||
:edit-label="t('Edit key')"
|
||||
:reset-label="t('Reset to match the label')"
|
||||
@update:value="emit('key', $event)"
|
||||
@unlock="emit('unlock')"
|
||||
@reset="emit('reset')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FieldTypePicker
|
||||
:model-value="type"
|
||||
:compact="compact"
|
||||
:disabled="disabled"
|
||||
@update:model-value="emit('type', $event)"
|
||||
/>
|
||||
|
||||
<div class="grid items-start gap-3.5" :class="gridClass">
|
||||
<div class="flex min-w-0 flex-col gap-3.5">
|
||||
<AppSwitch
|
||||
:model-value="field.required"
|
||||
:label="t('Required')"
|
||||
:description="t('An entry can\'t be saved with this field empty.')"
|
||||
:disabled="disabled"
|
||||
:ui="SWITCH_UI"
|
||||
@update:model-value="emit('required', Boolean($event))"
|
||||
/>
|
||||
<AppSwitch
|
||||
:model-value="canUnique && field.unique"
|
||||
:label="t('Unique values')"
|
||||
:description="uniqueHelp"
|
||||
:disabled="disabled || !canUnique"
|
||||
:ui="SWITCH_UI"
|
||||
@update:model-value="emit('unique', Boolean($event))"
|
||||
/>
|
||||
<AppSwitch
|
||||
v-if="type === 'boolean'"
|
||||
:model-value="field.defaultOn"
|
||||
:label="t('On by default')"
|
||||
:description="defaultOnHelp"
|
||||
:disabled="disabled"
|
||||
:ui="SWITCH_UI"
|
||||
@update:model-value="emit('defaultOn', Boolean($event))"
|
||||
/>
|
||||
<DirectoryField
|
||||
v-else
|
||||
:label="t('Default value')"
|
||||
name="default"
|
||||
:error="errors.default"
|
||||
:help="defaultHelp"
|
||||
>
|
||||
<USelect
|
||||
v-if="type === 'select'"
|
||||
:model-value="choiceValue"
|
||||
:items="choiceItems"
|
||||
:placeholder="t('None')"
|
||||
:disabled="disabled || field.unique"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="onChoice"
|
||||
/>
|
||||
<UTextarea
|
||||
v-else-if="type === 'richtext'"
|
||||
:model-value="field.defaultValue"
|
||||
:placeholder="t('No default')"
|
||||
:disabled="disabled || field.unique"
|
||||
:rows="3"
|
||||
class="w-full"
|
||||
@update:model-value="emit('default', String($event))"
|
||||
/>
|
||||
<UInput
|
||||
v-else
|
||||
:model-value="field.defaultValue"
|
||||
:type="type === 'number' ? 'number' : type === 'date' ? 'date' : 'text'"
|
||||
:placeholder="t('No default')"
|
||||
:disabled="disabled || field.unique"
|
||||
:color="errors.default ? 'error' : undefined"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('default', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
</div>
|
||||
|
||||
<div v-if="hasLimits" class="flex min-w-0 flex-col gap-3.5">
|
||||
<DirectoryField :label="limitLabels[0]" name="min" :error="errors.min">
|
||||
<UInput
|
||||
:model-value="field.min"
|
||||
:type="limitInput"
|
||||
:placeholder="t('No minimum')"
|
||||
:disabled="disabled"
|
||||
:color="errors.min ? 'error' : undefined"
|
||||
class="w-full"
|
||||
:ui="{ base: 'no-spin h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('limit', 'min', String($event))"
|
||||
>
|
||||
<template v-if="limitSuffix" #trailing>
|
||||
<span class="text-xs text-muted">{{ limitSuffix }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</DirectoryField>
|
||||
<DirectoryField
|
||||
:label="limitLabels[1]"
|
||||
name="max"
|
||||
:error="errors.max"
|
||||
:help="errors.min ? '' : maxHelp"
|
||||
>
|
||||
<UInput
|
||||
:model-value="field.max"
|
||||
:type="limitInput"
|
||||
:placeholder="t('No maximum')"
|
||||
:disabled="disabled"
|
||||
:color="errors.max ? 'error' : undefined"
|
||||
class="w-full"
|
||||
:ui="{ base: 'no-spin h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('limit', 'max', String($event))"
|
||||
>
|
||||
<template v-if="limitSuffix" #trailing>
|
||||
<span class="text-xs text-muted">{{ limitSuffix }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</DirectoryField>
|
||||
</div>
|
||||
<div v-else class="flex min-w-0 flex-col gap-1.5">
|
||||
<span class="font-medium text-highlighted">{{ t('Limits') }}</span>
|
||||
<span class="text-xs/4.5 text-muted">{{ noLimitsText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChoiceList
|
||||
v-if="type === 'select'"
|
||||
:options="field.options"
|
||||
:row-errors="errors.choices"
|
||||
:list-error="errors.options"
|
||||
:disabled="disabled"
|
||||
@add="emit('addChoice', $event)"
|
||||
@remove="emit('removeChoice', $event)"
|
||||
@rename="(index, text) => emit('renameChoice', index, text)"
|
||||
@reorder="emit('reorderChoices', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import FieldRow from '@/components/FieldRow.vue'
|
||||
import { useKeyboardReorder } from '@/composables/useKeyboardReorder'
|
||||
import { BUILDER_LIMITS, type BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: BuilderField[]
|
||||
selectedUid: number | null
|
||||
errors: Map<number, FieldErrors>
|
||||
listError?: string
|
||||
disabled: boolean
|
||||
mobile: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [uid: number]
|
||||
add: []
|
||||
remove: [uid: number]
|
||||
move: [uid: number, delta: number]
|
||||
reorder: [order: number[]]
|
||||
}>()
|
||||
|
||||
const list = ref<HTMLElement | null>(null)
|
||||
const focusedUid = ref<number | null>(null)
|
||||
|
||||
const reorder = useKeyboardReorder<BuilderField>({
|
||||
items: () => props.fields,
|
||||
name: (field) => field.label.trim() || t('Untitled field'),
|
||||
commit: (next) =>
|
||||
emit(
|
||||
'reorder',
|
||||
next.map((field) => field.uid),
|
||||
),
|
||||
root: () => list.value,
|
||||
})
|
||||
|
||||
const rows = computed(() => reorder.shown.value)
|
||||
const tabbableUid = computed(
|
||||
() => focusedUid.value ?? props.selectedUid ?? props.fields[0]?.uid ?? null,
|
||||
)
|
||||
const full = computed(() => props.fields.length >= BUILDER_LIMITS.fieldsMax)
|
||||
const countText = computed(() => t('{0} of {1}', [props.fields.length, BUILDER_LIMITS.fieldsMax]))
|
||||
|
||||
async function onMove(uid: number, delta: number) {
|
||||
const action = delta < 0 ? 'up' : 'down'
|
||||
emit('move', uid, delta)
|
||||
await nextTick()
|
||||
const row = list.value?.querySelector(`li[data-uid="${uid}"]`)
|
||||
const same = row?.querySelector<HTMLButtonElement>(`[data-action="${action}"]`)
|
||||
const other = row?.querySelector<HTMLButtonElement>(
|
||||
`[data-action="${action === 'up' ? 'down' : 'up'}"]`,
|
||||
)
|
||||
;(same && !same.disabled ? same : other)?.focus()
|
||||
}
|
||||
|
||||
function onRowKeydown(event: KeyboardEvent) {
|
||||
if (reorder.active.value || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) {
|
||||
return
|
||||
}
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.matches('[data-field-row]')) {
|
||||
return
|
||||
}
|
||||
const buttons = Array.from(list.value?.querySelectorAll<HTMLElement>('[data-field-row]') ?? [])
|
||||
const next = buttons[buttons.indexOf(target) + (event.key === 'ArrowUp' ? -1 : 1)]
|
||||
if (next) {
|
||||
event.preventDefault()
|
||||
next.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function onFocusin(event: FocusEvent) {
|
||||
const uid = (event.target as HTMLElement).closest('li[data-uid]')?.getAttribute('data-uid')
|
||||
focusedUid.value = uid ? Number(uid) : null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<div class="flex h-7 items-center justify-between">
|
||||
<h2 class="font-semibold text-highlighted">{{ t('Fields') }}</h2>
|
||||
<span class="font-mono text-xs text-muted" data-testid="field-count">{{
|
||||
countText
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="reorder-live"
|
||||
:class="
|
||||
reorder.active.value
|
||||
? 'bg-inverted px-2.5 py-2 text-xs/4.5 text-inverted'
|
||||
: 'sr-only'
|
||||
"
|
||||
>
|
||||
{{ reorder.announcement.value }}
|
||||
</div>
|
||||
|
||||
<ul
|
||||
v-if="rows.length"
|
||||
ref="list"
|
||||
class="m-0 flex list-none flex-col gap-2 p-0"
|
||||
@keydown="onRowKeydown"
|
||||
@focusin="onFocusin"
|
||||
>
|
||||
<FieldRow
|
||||
v-for="(field, index) in rows"
|
||||
:key="field.uid"
|
||||
:field="field"
|
||||
:index="index"
|
||||
:total="rows.length"
|
||||
:selected="!mobile && field.uid === selectedUid"
|
||||
:invalid="errors.has(field.uid)"
|
||||
:grabbed="reorder.grabbed.value === index"
|
||||
:disabled="disabled"
|
||||
:tabbable="field.uid === tabbableUid"
|
||||
:show-actions="!mobile"
|
||||
@select="emit('select', field.uid)"
|
||||
@up="onMove(field.uid, -1)"
|
||||
@down="onMove(field.uid, 1)"
|
||||
@remove="emit('remove', field.uid)"
|
||||
@grip-keydown="reorder.onKeydown($event, index)"
|
||||
/>
|
||||
</ul>
|
||||
<div
|
||||
v-else
|
||||
ref="list"
|
||||
data-testid="field-empty"
|
||||
class="flex flex-col items-center gap-2 border border-dashed px-4 py-8 text-center"
|
||||
:class="listError ? 'border-error' : 'border-accented'"
|
||||
>
|
||||
<UIcon name="i-lucide-rows-3" class="size-5 text-muted" />
|
||||
<span class="font-semibold">{{ t('No fields yet') }}</span>
|
||||
<span :class="listError ? 'text-error' : 'text-muted'">{{
|
||||
listError || t('Add the first field.')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="disabled || full"
|
||||
class="h-11 w-full justify-center border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
|
||||
@click="emit('add')"
|
||||
>
|
||||
{{ t('Add field') }}
|
||||
</UButton>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import BuilderBadge from '@/components/BuilderBadge.vue'
|
||||
import FieldActions from '@/components/FieldActions.vue'
|
||||
import type { BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import { fieldIcon } from '@/lib/schemaFields'
|
||||
|
||||
const props = defineProps<{
|
||||
field: BuilderField
|
||||
index: number
|
||||
total: number
|
||||
selected: boolean
|
||||
invalid: boolean
|
||||
grabbed: boolean
|
||||
disabled: boolean
|
||||
tabbable: boolean
|
||||
showActions: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: []
|
||||
up: []
|
||||
down: []
|
||||
remove: []
|
||||
'grip-keydown': [event: KeyboardEvent]
|
||||
}>()
|
||||
|
||||
const name = computed(() => props.field.label.trim() || t('Untitled field'))
|
||||
const key = computed(() => props.field.key || '–')
|
||||
|
||||
const rowClass = computed(() => [
|
||||
props.invalid ? 'border-error' : props.grabbed ? 'border-primary' : 'border-default',
|
||||
props.grabbed ? 'shadow-(--shadow-popover) outline-2 -outline-offset-1 outline-primary' : '',
|
||||
props.selected ? 'bg-(--rc-sand) text-(--rc-on-sand)' : 'bg-default text-highlighted',
|
||||
props.disabled ? 'opacity-60' : '',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
data-testid="field-row"
|
||||
:data-uid="field.uid"
|
||||
class="flex min-h-14 items-center gap-0.5 border py-1.5 pr-2 pl-1 max-md:min-h-16 max-md:gap-1"
|
||||
:class="rowClass"
|
||||
@click="$emit('select')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-grip
|
||||
:data-handle="index"
|
||||
:aria-label="t('Move {0}', [name])"
|
||||
:aria-pressed="grabbed"
|
||||
:disabled="disabled"
|
||||
:tabindex="tabbable ? 0 : -1"
|
||||
class="flex h-8 w-6 shrink-0 items-center justify-center focus-visible:outline-2 focus-visible:outline-primary max-md:h-11 max-md:w-9"
|
||||
:class="selected ? 'text-(--rc-on-sand)' : 'text-muted'"
|
||||
@keydown="$emit('grip-keydown', $event)"
|
||||
@click.stop
|
||||
>
|
||||
<UIcon name="i-lucide-grip-vertical" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-field-row
|
||||
:data-uid="field.uid"
|
||||
:aria-current="selected ? 'true' : undefined"
|
||||
:disabled="disabled"
|
||||
:tabindex="tabbable ? 0 : -1"
|
||||
class="flex min-w-0 flex-1 items-center gap-2 self-stretch text-left focus-visible:outline-2 focus-visible:outline-primary"
|
||||
@click.stop="$emit('select')"
|
||||
>
|
||||
<span
|
||||
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" />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate font-medium" data-testid="field-row-label">{{ name }}</span>
|
||||
<span
|
||||
class="truncate font-mono text-xs"
|
||||
:class="selected ? 'text-(--rc-on-sand)' : 'text-muted'"
|
||||
data-testid="field-row-key"
|
||||
>{{ key }}</span
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
<span class="flex shrink-0 items-center gap-1" data-testid="field-row-badges">
|
||||
<BuilderBadge kind="new" />
|
||||
<BuilderBadge v-if="field.required" kind="required" :on-sand="selected" />
|
||||
<BuilderBadge v-if="field.unique" kind="unique" :on-sand="selected" />
|
||||
<UIcon
|
||||
v-if="invalid"
|
||||
name="i-lucide-circle-alert"
|
||||
data-testid="field-row-alert"
|
||||
class="size-4 text-error"
|
||||
/>
|
||||
</span>
|
||||
<FieldActions
|
||||
v-if="selected && showActions"
|
||||
:first="index === 0"
|
||||
:last="index === total - 1"
|
||||
:disabled="disabled"
|
||||
@up="$emit('up')"
|
||||
@down="$emit('down')"
|
||||
@remove="$emit('remove')"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { ref } from 'vue'
|
||||
import FieldActions from '@/components/FieldActions.vue'
|
||||
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
|
||||
|
||||
defineProps<{ title: string; first: boolean; last: boolean; disabled: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ up: []; down: []; remove: []; closed: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const titleElement = ref<HTMLElement | null>(null)
|
||||
|
||||
function focusTitle(event: Event) {
|
||||
event.preventDefault()
|
||||
titleElement.value?.focus()
|
||||
}
|
||||
|
||||
function returnFocus(event: Event) {
|
||||
event.preventDefault()
|
||||
emit('closed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="title"
|
||||
:description="title"
|
||||
:content="{ onOpenAutoFocus: focusTitle, onCloseAutoFocus: returnFocus }"
|
||||
:ui="BOTTOM_SHEET_UI"
|
||||
>
|
||||
<template #title>
|
||||
<span
|
||||
ref="titleElement"
|
||||
tabindex="-1"
|
||||
data-testid="field-sheet-title"
|
||||
class="outline-none"
|
||||
>{{ title }}</span
|
||||
>
|
||||
</template>
|
||||
<template #body>
|
||||
<FieldActions
|
||||
:first="first"
|
||||
:last="last"
|
||||
:disabled="disabled"
|
||||
class="self-end"
|
||||
@up="emit('up')"
|
||||
@down="emit('down')"
|
||||
@remove="emit('remove')"
|
||||
/>
|
||||
<slot />
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton class="h-11 flex-1 justify-center" @click="open = false">
|
||||
{{ t('Done') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { FIELD_TYPES, type FieldType } from '@/lib/schemaBuilderModel'
|
||||
import { fieldIcon, fieldTypeLabel } from '@/lib/schemaFields'
|
||||
|
||||
defineProps<{ modelValue: FieldType; compact: boolean; disabled: boolean }>()
|
||||
|
||||
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) })),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<URadioGroup
|
||||
:model-value="modelValue"
|
||||
:items="items"
|
||||
:disabled="disabled"
|
||||
:legend="t('Type')"
|
||||
variant="card"
|
||||
indicator="hidden"
|
||||
orientation="horizontal"
|
||||
:ui="{
|
||||
root: 'w-full',
|
||||
legend: 'mb-1.5 font-medium text-highlighted',
|
||||
fieldset: compact ? 'grid grid-cols-2 gap-2' : 'grid grid-cols-3 gap-2',
|
||||
item: 'group min-h-14 rounded-none border-accented p-2.5 text-highlighted has-data-[state=checked]:border-primary has-data-[state=checked]:bg-(--rc-sand) has-data-[state=checked]:text-(--rc-on-sand) max-md:min-h-16',
|
||||
wrapper: 'ms-0 w-full',
|
||||
label: 'w-full text-base font-medium text-inherit',
|
||||
}"
|
||||
@update:model-value="$emit('update:modelValue', $event as FieldType)"
|
||||
>
|
||||
<template #label="{ item }">
|
||||
<span class="flex items-start gap-2.5 text-left">
|
||||
<UIcon :name="fieldIcon(item.value)" 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
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</URadioGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{ title: string; description: string }>()
|
||||
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAlert
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<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="$emit('retry')"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
</template>
|
||||
@@ -16,6 +16,9 @@ const props = withDefaults(
|
||||
help?: string
|
||||
manualHelp?: string
|
||||
locked?: boolean
|
||||
disabled?: boolean
|
||||
dense?: boolean
|
||||
resetInLabel?: boolean
|
||||
lockedHelp?: string
|
||||
editLabel?: string
|
||||
resetLabel?: string
|
||||
@@ -30,6 +33,9 @@ const props = withDefaults(
|
||||
help: '',
|
||||
manualHelp: '',
|
||||
locked: false,
|
||||
disabled: false,
|
||||
dense: false,
|
||||
resetInLabel: false,
|
||||
lockedHelp: '',
|
||||
editLabel: '',
|
||||
resetLabel: '',
|
||||
@@ -50,7 +56,7 @@ const emit = defineEmits<{
|
||||
const readonly = computed(() => props.locked || props.mode === 'empty' || props.mode === 'auto')
|
||||
const showAuto = computed(() => props.mode === 'auto' && !props.locked)
|
||||
const inputUi = computed(() => ({
|
||||
base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5 pe-28 md:pe-24' : ''}`,
|
||||
base: `h-11 font-mono text-base text-ellipsis ${props.dense ? 'md:h-8' : 'md:h-10'} md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5 pe-28 md:pe-24' : ''}`,
|
||||
}))
|
||||
const showReset = computed(
|
||||
() => !props.locked && (props.mode === 'manual' || (props.mode === 'saved' && props.error)),
|
||||
@@ -72,9 +78,23 @@ const placeholderText = computed(() => props.placeholder || t('Generated from th
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-1.5">
|
||||
<DirectoryField :label="label" :name="name" :error="error" :help="shownHelp">
|
||||
<template v-if="resetInLabel && showReset" #hint>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="link"
|
||||
icon="i-lucide-rotate-ccw"
|
||||
:disabled="disabled"
|
||||
class="gap-1 p-0 font-sans text-xs font-medium text-highlighted underline underline-offset-3 max-md:hidden"
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
{{ resetText }}
|
||||
</UButton>
|
||||
</template>
|
||||
<UInput
|
||||
:model-value="value"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholderText"
|
||||
:maxlength="maxlength"
|
||||
:data-autofocus="autofocus ? '' : undefined"
|
||||
@@ -103,6 +123,7 @@ const placeholderText = computed(() => props.placeholder || t('Generated from th
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="pencilLabel"
|
||||
:disabled="disabled"
|
||||
class="size-7 justify-center text-highlighted max-md:size-11"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('unlock')"
|
||||
@@ -127,8 +148,10 @@ const placeholderText = computed(() => props.placeholder || t('Generated from th
|
||||
v-if="showReset"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
:disabled="disabled"
|
||||
icon="i-lucide-rotate-ccw"
|
||||
class="min-h-11 gap-1 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
|
||||
:class="resetInLabel ? 'md:hidden' : ''"
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import ApiPreview from '../ApiPreview.vue'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
const writeText = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
writeText.mockReset().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
})
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
const JSON_TEXT = '{\n "id": 42\n}'
|
||||
|
||||
const toggle = () => document.body.querySelector<HTMLElement>('[data-testid="api-preview-toggle"]')!
|
||||
|
||||
describe('ApiPreview', () => {
|
||||
it('is collapsed, then opens and closes with the toggle', async () => {
|
||||
await mountInApp(ApiPreview, { keyValue: 'recipes', json: JSON_TEXT })
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('false')
|
||||
expect(document.body.querySelector('[data-testid="api-json"]')).toBeNull()
|
||||
|
||||
toggle().click()
|
||||
await flushPromises()
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('true')
|
||||
expect(document.body.querySelector('[data-testid="api-json"]')!.textContent).toBe(JSON_TEXT)
|
||||
expect(holder.wrapper!.findComponent(ApiPreview).emitted('update:open')![0]).toEqual([true])
|
||||
})
|
||||
|
||||
it('shows the first route in the bar and falls back to events', async () => {
|
||||
await mountInApp(ApiPreview, { keyValue: '', json: JSON_TEXT })
|
||||
|
||||
expect(text(document.body.querySelector('[data-testid="api-preview"]'))).toContain(
|
||||
'GET /v1/content-type/events/entry',
|
||||
)
|
||||
})
|
||||
|
||||
it('copies the JSON and reads Copied', async () => {
|
||||
await mountInApp(ApiPreview, { keyValue: 'recipes', json: JSON_TEXT })
|
||||
|
||||
document.body.querySelector<HTMLElement>('[data-testid="api-copy"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(JSON_TEXT)
|
||||
expect(text(document.body.querySelector('[data-testid="api-copy"]'))).toBe('Copied')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import ChoiceList from '../ChoiceList.vue'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return { options: ['Talk', 'Workshop', 'Meetup'], disabled: false, ...extra }
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return holder.wrapper!.findComponent(ChoiceList).emitted(name) ?? []
|
||||
}
|
||||
|
||||
const addInput = () =>
|
||||
document.body.querySelector<HTMLInputElement>('input[aria-label="Add a choice"]')!
|
||||
|
||||
async function type(input: HTMLInputElement, value: string) {
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function addButton() {
|
||||
return [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(candidate) => text(candidate) === 'Add',
|
||||
)!
|
||||
}
|
||||
|
||||
describe('ChoiceList', () => {
|
||||
it('shows the count, the rows and the help', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
|
||||
expect(text(document.body.querySelector('[data-testid="choice-count"]'))).toBe('3 of 50')
|
||||
expect(document.body.querySelectorAll('[data-testid="choice-row"]')).toHaveLength(3)
|
||||
expect(text(document.body)).toContain('Add 1 to 50 choices.')
|
||||
})
|
||||
|
||||
it('emits add from the button and from Enter, and ignores a blank draft', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
expect(addButton().disabled).toBe(true)
|
||||
|
||||
await type(addInput(), 'Panel')
|
||||
addButton().click()
|
||||
await flushPromises()
|
||||
await type(addInput(), 'Social')
|
||||
addInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
await flushPromises()
|
||||
await type(addInput(), ' ')
|
||||
addInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('add')).toEqual([['Panel'], ['Social']])
|
||||
expect(addInput().value).toBe(' ')
|
||||
})
|
||||
|
||||
it('emits rename and remove for a row', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
|
||||
await type(
|
||||
document.body.querySelectorAll<HTMLInputElement>(
|
||||
'[data-testid="choice-row"] input',
|
||||
)[1]!,
|
||||
'Seminar',
|
||||
)
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Remove Talk"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('rename')).toEqual([[1, 'Seminar']])
|
||||
expect(emitted('remove')).toEqual([[0]])
|
||||
})
|
||||
|
||||
it('shows a row error with an alert icon and ties it to the input', async () => {
|
||||
await mountInApp(ChoiceList, props({ rowErrors: { 1: 'Two choices are the same.' } }))
|
||||
|
||||
const row = document.body.querySelectorAll('[data-testid="choice-row"]')[1]!
|
||||
expect(text(row.querySelector('[role="alert"]'))).toBe('Two choices are the same.')
|
||||
expect(row.querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
const input = row.querySelector('input')!
|
||||
expect(input.getAttribute('aria-invalid')).toBe('true')
|
||||
expect(input.getAttribute('aria-describedby')).toBe('choice-error-1')
|
||||
})
|
||||
|
||||
it('shows a list error and disables Add at 50 choices', async () => {
|
||||
await mountInApp(
|
||||
ChoiceList,
|
||||
props({ options: Array.from({ length: 50 }, (_, index) => `C${index}`) }),
|
||||
)
|
||||
await type(addInput(), 'more')
|
||||
|
||||
expect(addButton().disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the list error', async () => {
|
||||
await mountInApp(ChoiceList, props({ options: [], listError: 'Add at least one choice.' }))
|
||||
|
||||
expect(text(document.body.querySelector('p[role="alert"]'))).toBe(
|
||||
'Add at least one choice.',
|
||||
)
|
||||
})
|
||||
|
||||
it('emits the new order on drop only', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
const grip = () => document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
|
||||
const key = async (name: string) => {
|
||||
grip().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
await key(' ')
|
||||
await key('ArrowDown')
|
||||
expect(emitted('reorder')).toHaveLength(0)
|
||||
await key(' ')
|
||||
|
||||
expect(emitted('reorder')).toEqual([[['Workshop', 'Talk', 'Meetup']]])
|
||||
})
|
||||
|
||||
it('keeps each error with its choice while a row is being moved', async () => {
|
||||
await mountInApp(
|
||||
ChoiceList,
|
||||
props({
|
||||
options: ['Talk', 'Talk', 'Meetup'],
|
||||
rowErrors: { 1: 'Two choices are the same.' },
|
||||
}),
|
||||
)
|
||||
const grip = document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
const rows = [...document.body.querySelectorAll('[data-testid="choice-row"]')]
|
||||
expect(rows.map((row) => text(row.querySelector('[role="alert"]')))).toEqual([
|
||||
'Two choices are the same.',
|
||||
'',
|
||||
'',
|
||||
])
|
||||
})
|
||||
|
||||
it('emits the data index for rename and remove while a row is being moved', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
const grip = document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
const removers = [...document.body.querySelectorAll<HTMLButtonElement>('button')].filter(
|
||||
(candidate) => candidate.getAttribute('aria-label')?.startsWith('Remove '),
|
||||
)
|
||||
removers[0]!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('remove')).toEqual([[1]])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import FieldInspector from '../FieldInspector.vue'
|
||||
import { createField } from '@/lib/schemaBuilderModel'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
field: createField(1, { label: 'Title', key: 'title' }),
|
||||
errors: {},
|
||||
disabled: false,
|
||||
compact: false,
|
||||
focusToken: 0,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return holder.wrapper!.findComponent(FieldInspector).emitted(name) ?? []
|
||||
}
|
||||
|
||||
async function type(selector: string, value: string) {
|
||||
const input = document.body.querySelector<HTMLInputElement>(selector)!
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('FieldInspector', () => {
|
||||
it('emits label, key, type, limit and default edits', async () => {
|
||||
await mountInApp(
|
||||
FieldInspector,
|
||||
props({ field: createField(1, { label: 'T', key: 't', keyMode: 'manual' }) }),
|
||||
)
|
||||
|
||||
await type('input[name="label"]', 'Headline')
|
||||
await type('input[name="field-key"]', 'headline')
|
||||
await type('input[name="max"]', '80')
|
||||
await type('input[name="default"]', 'Draft')
|
||||
document.body.querySelector<HTMLElement>('[role="radio"][value="number"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('label')).toEqual([['Headline']])
|
||||
expect(emitted('key')).toEqual([['headline']])
|
||||
expect(emitted('limit')).toEqual([['max', '80']])
|
||||
expect(emitted('default')).toEqual([['Draft']])
|
||||
expect(emitted('type')).toEqual([['number']])
|
||||
})
|
||||
|
||||
it('emits the Required switch', async () => {
|
||||
await mountInApp(FieldInspector, props())
|
||||
|
||||
const label = [...document.body.querySelectorAll('label')].find((node) =>
|
||||
text(node).startsWith('Required'),
|
||||
)!
|
||||
document.getElementById(label.getAttribute('for')!)!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('required')).toEqual([[true]])
|
||||
})
|
||||
|
||||
it('dims and disables every control while disabled', async () => {
|
||||
await mountInApp(FieldInspector, props({ disabled: true }))
|
||||
|
||||
const root = document.body.querySelector('[data-testid="field-inspector"]')!
|
||||
expect(root.className).toContain('opacity-60')
|
||||
expect(
|
||||
[...root.querySelectorAll<HTMLInputElement>('input')].every((input) => input.disabled),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the errors under their controls', async () => {
|
||||
await mountInApp(
|
||||
FieldInspector,
|
||||
props({
|
||||
field: createField(1, { type: 'number', min: '6', max: '5', defaultValue: '0' }),
|
||||
errors: {
|
||||
label: 'Give the field a label.',
|
||||
key: 'Bad key.',
|
||||
min: 'Order.',
|
||||
default: 'Outside.',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const alerts = [...document.body.querySelectorAll('[role="alert"]')].map(text)
|
||||
expect(alerts).toEqual(['Give the field a label.', 'Bad key.', 'Outside.', 'Order.'])
|
||||
})
|
||||
|
||||
it('lays the controls out in one column when compact', async () => {
|
||||
await mountInApp(FieldInspector, props({ compact: true }))
|
||||
|
||||
expect(document.body.querySelector('.grid-cols-1')).not.toBeNull()
|
||||
expect(document.body.querySelector('.grid-cols-2')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import FieldList from '../FieldList.vue'
|
||||
import { createField, type BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
const FIELDS: BuilderField[] = [
|
||||
createField(1, { label: 'Title', key: 'title' }),
|
||||
createField(2, { label: 'Starts on', key: 'starts_on', type: 'date' }),
|
||||
createField(3, { label: 'Ends on', key: 'ends_on', type: 'date' }),
|
||||
]
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
fields: FIELDS,
|
||||
selectedUid: 2,
|
||||
errors: new Map(),
|
||||
disabled: false,
|
||||
mobile: false,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return holder.wrapper!.findComponent(FieldList).emitted(name) ?? []
|
||||
}
|
||||
|
||||
function grip(label: string) {
|
||||
return document.body.querySelector<HTMLElement>(`[aria-label="Move ${label}"]`)!
|
||||
}
|
||||
|
||||
async function press(target: Element, key: string) {
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function labels() {
|
||||
return [...document.body.querySelectorAll('[data-testid="field-row-label"]')].map(text)
|
||||
}
|
||||
|
||||
describe('FieldList', () => {
|
||||
it('lists the rows and counts them against 50', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
expect(labels()).toEqual(['Title', 'Starts on', 'Ends on'])
|
||||
expect(text(document.body.querySelector('[data-testid="field-count"]'))).toBe('3 of 50')
|
||||
})
|
||||
|
||||
it('emits select, add, move and remove', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
document.body.querySelector<HTMLElement>('[data-field-row][data-uid="3"]')!.click()
|
||||
;(document.body.querySelector('[data-action="up"]') as HTMLElement).click()
|
||||
;(document.body.querySelector('[data-action="remove"]') as HTMLElement).click()
|
||||
const add = [...document.body.querySelectorAll('button')].find(
|
||||
(candidate) => text(candidate) === 'Add field',
|
||||
)!
|
||||
add.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('select')).toEqual([[3]])
|
||||
expect(emitted('move')).toEqual([[2, -1]])
|
||||
expect(emitted('remove')).toEqual([[2]])
|
||||
expect(emitted('add')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('disables Add field at 50 rows and when disabled', async () => {
|
||||
const many = Array.from({ length: 50 }, (_, index) =>
|
||||
createField(index + 1, { label: 'F' }),
|
||||
)
|
||||
await mountInApp(FieldList, props({ fields: many, selectedUid: 1 }))
|
||||
|
||||
const add = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(candidate) => text(candidate) === 'Add field',
|
||||
)!
|
||||
expect(add.disabled).toBe(true)
|
||||
expect(text(document.body.querySelector('[data-testid="field-count"]'))).toBe('50 of 50')
|
||||
})
|
||||
|
||||
it('shows the empty state with the error message in the error colour', async () => {
|
||||
await mountInApp(FieldList, props({ fields: [], selectedUid: null, listError: 'Nope.' }))
|
||||
|
||||
const empty = document.body.querySelector('[data-testid="field-empty"]')!
|
||||
expect(text(empty)).toBe('No fields yet Nope.')
|
||||
expect(empty.querySelector('.text-error')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('emits the new order once, on drop, and nothing on cancel', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
await press(grip('Starts on'), 'ArrowUp')
|
||||
await press(grip('Starts on'), 'Escape')
|
||||
expect(emitted('reorder')).toHaveLength(0)
|
||||
expect(labels()).toEqual(['Title', 'Starts on', 'Ends on'])
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
await press(grip('Starts on'), 'ArrowUp')
|
||||
expect(emitted('reorder')).toHaveLength(0)
|
||||
await press(grip('Starts on'), ' ')
|
||||
|
||||
expect(emitted('reorder')).toEqual([[[2, 1, 3]]])
|
||||
})
|
||||
|
||||
it('moves the focus between rows with the arrow keys', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
const rows = [...document.body.querySelectorAll<HTMLElement>('[data-field-row]')]
|
||||
rows[0]!.focus()
|
||||
|
||||
await press(rows[0]!, 'ArrowDown')
|
||||
expect(document.activeElement).toBe(rows[1])
|
||||
await press(rows[1]!, 'ArrowUp')
|
||||
expect(document.activeElement).toBe(rows[0])
|
||||
})
|
||||
|
||||
it('keeps one roving tab stop for the rows', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
const stops = [...document.body.querySelectorAll<HTMLElement>('[data-field-row]')].filter(
|
||||
(row) => row.tabIndex === 0,
|
||||
)
|
||||
expect(stops.map((row) => row.getAttribute('data-uid'))).toEqual(['2'])
|
||||
})
|
||||
|
||||
it('hides the row actions on mobile', async () => {
|
||||
await mountInApp(FieldList, props({ mobile: true }))
|
||||
|
||||
expect(document.body.querySelector('[data-action]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -97,3 +97,61 @@ describe('listBuiltinTypes', () => {
|
||||
expect(failure.status).toBe(status)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createContentType', () => {
|
||||
const payload = {
|
||||
key: 'recipes',
|
||||
name: 'Recipes',
|
||||
fields: [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('posts the JSON body to /v1/content-type and returns the created type', async () => {
|
||||
answer(201, recipes)
|
||||
|
||||
await expect(useContentTypes().createContentType(payload)).resolves.toEqual(recipes)
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]!
|
||||
expect(url).toBe('/v1/content-type')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
|
||||
expect(JSON.parse(init.body)).toEqual(payload)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[400, 'invalid_content_type_key'],
|
||||
[409, 'content_type_key_taken'],
|
||||
[422, 'invalid_field'],
|
||||
[403, 'Only owners and admins can do this'],
|
||||
[500, 'boom'],
|
||||
])('rejects %i with its plain-text body', async (status, body) => {
|
||||
answer(status, body)
|
||||
|
||||
const failure = await useContentTypes()
|
||||
.createContentType(payload)
|
||||
.catch((error) => error)
|
||||
|
||||
expect(failure).toBeInstanceOf(ApiError)
|
||||
expect(failure.status).toBe(status)
|
||||
expect(failure.body).toBe(body)
|
||||
})
|
||||
|
||||
it('lets a network failure through', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
|
||||
await expect(useContentTypes().createContentType(payload)).rejects.toBeInstanceOf(
|
||||
NetworkError,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useContentTypes } from '@/composables/useContentTypes'
|
||||
import { useSchemaGate, type SchemaGateScope } from '@/composables/useSchemaGate'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
vi.mock('@/composables/useContentTypes', () => ({ useContentTypes: vi.fn() }))
|
||||
|
||||
const listContentTypes = vi.fn()
|
||||
const listBuiltinTypes = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
listContentTypes.mockReset()
|
||||
listBuiltinTypes.mockReset()
|
||||
vi.mocked(useContentTypes).mockReturnValue({ listContentTypes, listBuiltinTypes })
|
||||
const session = useSessionStore()
|
||||
session.$patch({ status: 'ready', session: { role: 'owner' } })
|
||||
})
|
||||
|
||||
async function gate(scope: SchemaGateScope) {
|
||||
const store = useContentTypesStore()
|
||||
await store.load({ builtin: true })
|
||||
return useSchemaGate(scope)
|
||||
}
|
||||
|
||||
describe('schema gate forbidden', () => {
|
||||
it.each([
|
||||
['custom', true],
|
||||
['builtin', false],
|
||||
['all', true],
|
||||
] as const)('scope %s with custom 403 is %s', async (scope, expected) => {
|
||||
listContentTypes.mockRejectedValue(new ApiError(403))
|
||||
listBuiltinTypes.mockResolvedValue([])
|
||||
|
||||
expect((await gate(scope)).forbidden.value).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['custom', false],
|
||||
['builtin', true],
|
||||
['all', true],
|
||||
] as const)('scope %s with builtin 403 is %s', async (scope, expected) => {
|
||||
listContentTypes.mockResolvedValue([])
|
||||
listBuiltinTypes.mockRejectedValue(new ApiError(403))
|
||||
|
||||
expect((await gate(scope)).forbidden.value).toBe(expected)
|
||||
})
|
||||
|
||||
it('is forbidden for a session that cannot manage content, whatever the scope', async () => {
|
||||
listContentTypes.mockResolvedValue([])
|
||||
listBuiltinTypes.mockResolvedValue([])
|
||||
useSessionStore().$patch({ session: { role: 'user' } })
|
||||
|
||||
expect((await gate('custom')).forbidden.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { jsonInit } from '@/lib/directoryRequest'
|
||||
import type { ContentTypePayload } from '@/lib/schemaBuilderPayload'
|
||||
|
||||
export interface BuiltinField {
|
||||
key: string
|
||||
@@ -48,5 +50,12 @@ export function useContentTypes() {
|
||||
return requestList<BuiltinType>('/v1/builtin-type')
|
||||
}
|
||||
|
||||
return { listContentTypes, listBuiltinTypes }
|
||||
function createContentType(payload: ContentTypePayload): Promise<ContentType> {
|
||||
return request<ContentType>('/v1/content-type', {
|
||||
...jsonInit('POST', payload),
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
return { listContentTypes, listBuiltinTypes, createContentType }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { computed } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
|
||||
export function useJsonCopy() {
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
const label = computed(() => (copied.value ? t('Copied') : t('Copy JSON')))
|
||||
const icon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
|
||||
return { copied, copy, label, icon }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { computed, nextTick, ref, shallowRef } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { moveItem } from '@/lib/arrayMove'
|
||||
|
||||
interface KeyboardReorderOptions<T> {
|
||||
items: () => readonly T[]
|
||||
name: (item: T) => string
|
||||
commit: (next: T[]) => void
|
||||
root: () => HTMLElement | null
|
||||
}
|
||||
|
||||
export function useKeyboardReorder<T>(options: KeyboardReorderOptions<T>) {
|
||||
const working = shallowRef<T[] | null>(null)
|
||||
const grabbed = ref<number | null>(null)
|
||||
const origin = ref(0)
|
||||
const announcement = ref('')
|
||||
|
||||
const shown = computed<readonly T[]>(() => working.value ?? options.items())
|
||||
const active = computed(() => grabbed.value !== null)
|
||||
|
||||
function focusHandle(index: number) {
|
||||
nextTick(() =>
|
||||
options.root()?.querySelector<HTMLElement>(`[data-handle="${index}"]`)?.focus(),
|
||||
)
|
||||
}
|
||||
|
||||
function movingText(item: T, position: number, total: number) {
|
||||
return t(
|
||||
'Moving {0}. Position {1} of {2}. Press Up or Down to move, Space to drop, Esc to cancel.',
|
||||
[options.name(item), position, total],
|
||||
)
|
||||
}
|
||||
|
||||
function grab(index: number) {
|
||||
const items = [...options.items()]
|
||||
const item = items[index]
|
||||
if (item === undefined) {
|
||||
return
|
||||
}
|
||||
working.value = items
|
||||
grabbed.value = index
|
||||
origin.value = index
|
||||
announcement.value = movingText(item, index + 1, items.length)
|
||||
}
|
||||
|
||||
function move(delta: number) {
|
||||
const current = grabbed.value
|
||||
const items = working.value
|
||||
if (current === null || !items) {
|
||||
return
|
||||
}
|
||||
const target = current + delta
|
||||
if (target < 0 || target >= items.length) {
|
||||
return
|
||||
}
|
||||
working.value = moveItem(items, current, target)
|
||||
grabbed.value = target
|
||||
announcement.value = movingText(items[current]!, target + 1, items.length)
|
||||
focusHandle(target)
|
||||
}
|
||||
|
||||
function release() {
|
||||
working.value = null
|
||||
grabbed.value = null
|
||||
}
|
||||
|
||||
function drop() {
|
||||
const current = grabbed.value
|
||||
const items = working.value
|
||||
if (current === null || !items) {
|
||||
return
|
||||
}
|
||||
announcement.value = t('{0} dropped. Position {1} of {2}.', [
|
||||
options.name(items[current]!),
|
||||
current + 1,
|
||||
items.length,
|
||||
])
|
||||
release()
|
||||
if (current !== origin.value) {
|
||||
options.commit(items)
|
||||
}
|
||||
focusHandle(current)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
const current = grabbed.value
|
||||
const items = working.value
|
||||
if (current === null || !items) {
|
||||
return
|
||||
}
|
||||
const index = origin.value
|
||||
announcement.value = t('Reorder cancelled. {0} is back at position {1} of {2}.', [
|
||||
options.name(items[current]!),
|
||||
index + 1,
|
||||
items.length,
|
||||
])
|
||||
release()
|
||||
focusHandle(index)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent, index: number) {
|
||||
if (grabbed.value === null) {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
grab(index)
|
||||
}
|
||||
return
|
||||
}
|
||||
const actions: Record<string, () => void> = {
|
||||
ArrowUp: () => move(-1),
|
||||
ArrowDown: () => move(1),
|
||||
' ': drop,
|
||||
Enter: drop,
|
||||
Escape: cancel,
|
||||
}
|
||||
const action = actions[event.key]
|
||||
if (action) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
return { shown, grabbed, active, announcement, onKeydown }
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useContentTypes, type ContentType } from '@/composables/useContentTypes'
|
||||
import { moveItem } from '@/lib/arrayMove'
|
||||
import { SCHEMA_PATH } from '@/lib/routePaths'
|
||||
import { deriveFieldKey, deriveTypeKey } from '@/lib/schemaBuilderKeys'
|
||||
import {
|
||||
BUILDER_LIMITS,
|
||||
blankForm,
|
||||
changeFieldType,
|
||||
clearStaleDefault,
|
||||
createField,
|
||||
type BuilderField,
|
||||
type BuilderForm,
|
||||
type BuilderStatus,
|
||||
type FieldType,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
import { buildPayload } from '@/lib/schemaBuilderPayload'
|
||||
import { FIELD_ERROR_TOUCH, fieldTouchKey, typeTouchKey } from '@/lib/schemaBuilderTouch'
|
||||
import type { FieldTouch, TypeTouch } from '@/lib/schemaBuilderTouch'
|
||||
import {
|
||||
countErrors,
|
||||
countProblems,
|
||||
validateForm,
|
||||
type FieldErrors,
|
||||
type FormErrors,
|
||||
} from '@/lib/schemaBuilderValidation'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
|
||||
const KEY_TAKEN_BODY = 'content_type_key_taken'
|
||||
const INVALID_KEY_BODY = 'invalid_content_type_key'
|
||||
const INVALID_FIELD_BODY = 'invalid_field'
|
||||
const TOAST_ID = 'content-type-saved'
|
||||
|
||||
export type SaveFailure = 'none' | 'failed' | 'invalid' | 'forbidden'
|
||||
export type SaveState = 'editing' | 'saving' | 'saved' | 'failed'
|
||||
|
||||
function signature(form: BuilderForm) {
|
||||
return JSON.stringify({
|
||||
name: form.name,
|
||||
key: form.key,
|
||||
keyMode: form.keyMode,
|
||||
fields: form.fields.map((field) => ({ ...field, uid: 0 })),
|
||||
})
|
||||
}
|
||||
|
||||
export function useSchemaBuilder() {
|
||||
const router = useRouter()
|
||||
const store = useContentTypesStore()
|
||||
const toast = useLocalizedToast()
|
||||
const online = useOnline()
|
||||
let nextUid = 1
|
||||
const newUid = () => nextUid++
|
||||
|
||||
const form = ref<BuilderForm>(blankForm(newUid()))
|
||||
let initialSignature = signature(form.value)
|
||||
const selectedUid = ref<number | null>(form.value.fields[0]!.uid)
|
||||
const touched = ref(new Set<string>())
|
||||
const submitted = ref(false)
|
||||
const saveState = ref<SaveState>('editing')
|
||||
const failure = ref<SaveFailure>('none')
|
||||
const keyServerError = ref('')
|
||||
const keyFocusToken = ref(0)
|
||||
const labelFocusToken = ref(0)
|
||||
|
||||
const saving = computed(() => saveState.value === 'saving')
|
||||
const dirty = computed(
|
||||
() => saveState.value !== 'saved' && signature(form.value) !== initialSignature,
|
||||
)
|
||||
const errors = computed(() => validateForm(form.value))
|
||||
const selected = computed(
|
||||
() => form.value.fields.find((field) => field.uid === selectedUid.value) ?? null,
|
||||
)
|
||||
|
||||
function touchKey(key: string) {
|
||||
if (!touched.value.has(key)) {
|
||||
touched.value = new Set(touched.value).add(key)
|
||||
}
|
||||
}
|
||||
|
||||
function touch(name: TypeTouch) {
|
||||
touchKey(typeTouchKey(name))
|
||||
}
|
||||
|
||||
function touchField(uid: number, name: FieldTouch) {
|
||||
touchKey(fieldTouchKey(uid, name))
|
||||
}
|
||||
|
||||
function isShown(key: string) {
|
||||
return submitted.value || touched.value.has(key)
|
||||
}
|
||||
|
||||
function shownFieldErrors(uid: number, all: FieldErrors) {
|
||||
const pick: Record<string, unknown> = {}
|
||||
for (const [errorKey, touchName] of FIELD_ERROR_TOUCH) {
|
||||
if (all[errorKey] && isShown(fieldTouchKey(uid, touchName))) {
|
||||
pick[errorKey] = all[errorKey]
|
||||
}
|
||||
}
|
||||
return pick as FieldErrors
|
||||
}
|
||||
|
||||
const shown = computed<FormErrors>(() => {
|
||||
const type = { ...errors.value.type }
|
||||
if (!isShown(typeTouchKey('name'))) delete type.name
|
||||
if (!isShown(typeTouchKey('key'))) delete type.key
|
||||
if (!isShown(typeTouchKey('fields'))) delete type.fields
|
||||
if (keyServerError.value) type.key = keyServerError.value
|
||||
const fields = new Map<number, FieldErrors>()
|
||||
for (const [uid, all] of errors.value.fields) {
|
||||
const pick = shownFieldErrors(uid, all)
|
||||
if (Object.keys(pick).length > 0) {
|
||||
fields.set(uid, pick)
|
||||
}
|
||||
}
|
||||
return { type, fields }
|
||||
})
|
||||
|
||||
const hasServerKeyError = computed(
|
||||
() => keyServerError.value !== '' && !errors.value.type.key,
|
||||
)
|
||||
const problems = computed(
|
||||
() => countProblems(errors.value) + (hasServerKeyError.value ? 1 : 0),
|
||||
)
|
||||
const hasHidden = computed(() => countErrors(errors.value) > countErrors(shown.value))
|
||||
const engaged = computed(() => dirty.value || touched.value.size > 0 || submitted.value)
|
||||
|
||||
const status = computed<BuilderStatus>(() => {
|
||||
if (saving.value) return 'saving'
|
||||
if (!online.value) return 'offline'
|
||||
if (failure.value === 'failed') return 'failed'
|
||||
if (problems.value > 0 && engaged.value) return 'problems'
|
||||
return dirty.value ? 'dirty' : 'none'
|
||||
})
|
||||
|
||||
const canSave = computed(
|
||||
() => !saving.value && online.value && dirty.value && problems.value === 0,
|
||||
)
|
||||
const canReveal = computed(
|
||||
() =>
|
||||
!saving.value && online.value && dirty.value && hasHidden.value && !submitted.value,
|
||||
)
|
||||
const canDiscard = computed(
|
||||
() =>
|
||||
!saving.value &&
|
||||
(dirty.value ||
|
||||
touched.value.size > 0 ||
|
||||
submitted.value ||
|
||||
keyServerError.value !== ''),
|
||||
)
|
||||
|
||||
function clearFailure() {
|
||||
if (failure.value !== 'none' && failure.value !== 'forbidden') {
|
||||
failure.value = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
function edited() {
|
||||
if (saveState.value === 'saved' || saveState.value === 'failed') {
|
||||
saveState.value = 'editing'
|
||||
}
|
||||
clearFailure()
|
||||
}
|
||||
|
||||
function otherKeys(uid: number) {
|
||||
return form.value.fields.filter((field) => field.uid !== uid).map((field) => field.key)
|
||||
}
|
||||
|
||||
function setName(name: string) {
|
||||
form.value.name = name
|
||||
touch('name')
|
||||
if (form.value.keyMode === 'auto') {
|
||||
form.value.key = deriveTypeKey(name)
|
||||
keyServerError.value = ''
|
||||
}
|
||||
edited()
|
||||
}
|
||||
|
||||
function setKey(key: string) {
|
||||
form.value.key = key
|
||||
form.value.keyMode = 'manual'
|
||||
keyServerError.value = ''
|
||||
touch('key')
|
||||
edited()
|
||||
}
|
||||
|
||||
function unlockKey() {
|
||||
form.value.keyMode = 'manual'
|
||||
}
|
||||
|
||||
function resetKey() {
|
||||
form.value.keyMode = 'auto'
|
||||
form.value.key = deriveTypeKey(form.value.name)
|
||||
keyServerError.value = ''
|
||||
edited()
|
||||
}
|
||||
|
||||
function indexOf(uid: number) {
|
||||
return form.value.fields.findIndex((field) => field.uid === uid)
|
||||
}
|
||||
|
||||
function replaceField(uid: number, next: BuilderField) {
|
||||
const index = indexOf(uid)
|
||||
if (index !== -1) {
|
||||
form.value.fields.splice(index, 1, next)
|
||||
}
|
||||
}
|
||||
|
||||
function patchField(uid: number, patch: Partial<BuilderField>, touchName?: FieldTouch) {
|
||||
const field = form.value.fields[indexOf(uid)]
|
||||
if (!field) {
|
||||
return
|
||||
}
|
||||
replaceField(uid, clearStaleDefault({ ...field, ...patch }))
|
||||
if (touchName) {
|
||||
touchField(uid, touchName)
|
||||
}
|
||||
edited()
|
||||
}
|
||||
|
||||
function setLabel(uid: number, label: string) {
|
||||
const field = form.value.fields[indexOf(uid)]
|
||||
if (!field) {
|
||||
return
|
||||
}
|
||||
const patch: Partial<BuilderField> = { label }
|
||||
if (field.keyMode === 'auto') {
|
||||
patch.key = deriveFieldKey(label, otherKeys(uid))
|
||||
}
|
||||
patchField(uid, patch, 'label')
|
||||
}
|
||||
|
||||
function setFieldKey(uid: number, key: string) {
|
||||
patchField(uid, { key, keyMode: 'manual' }, 'key')
|
||||
}
|
||||
|
||||
function unlockFieldKey(uid: number) {
|
||||
patchField(uid, { keyMode: 'manual' })
|
||||
}
|
||||
|
||||
function resetFieldKey(uid: number) {
|
||||
const field = form.value.fields[indexOf(uid)]
|
||||
if (field) {
|
||||
patchField(uid, { keyMode: 'auto', key: deriveFieldKey(field.label, otherKeys(uid)) })
|
||||
}
|
||||
}
|
||||
|
||||
function setFieldType(uid: number, type: FieldType) {
|
||||
const field = form.value.fields[indexOf(uid)]
|
||||
if (field) {
|
||||
replaceField(uid, changeFieldType(field, type))
|
||||
edited()
|
||||
}
|
||||
}
|
||||
|
||||
function setLimit(uid: number, which: 'min' | 'max', value: string) {
|
||||
patchField(uid, { [which]: value }, which)
|
||||
}
|
||||
|
||||
function setDefault(uid: number, value: string) {
|
||||
patchField(uid, { defaultValue: value }, 'default')
|
||||
}
|
||||
|
||||
function selectField(uid: number) {
|
||||
selectedUid.value = uid
|
||||
}
|
||||
|
||||
function addField() {
|
||||
if (form.value.fields.length >= BUILDER_LIMITS.fieldsMax) {
|
||||
return
|
||||
}
|
||||
const field = createField(newUid())
|
||||
form.value.fields.push(field)
|
||||
selectedUid.value = field.uid
|
||||
labelFocusToken.value++
|
||||
edited()
|
||||
}
|
||||
|
||||
function removeField(uid: number) {
|
||||
const index = indexOf(uid)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
form.value.fields.splice(index, 1)
|
||||
if (selectedUid.value === uid) {
|
||||
const next = form.value.fields[index] ?? form.value.fields[index - 1]
|
||||
selectedUid.value = next?.uid ?? null
|
||||
}
|
||||
if (form.value.fields.length === 0) {
|
||||
touch('fields')
|
||||
}
|
||||
edited()
|
||||
}
|
||||
|
||||
function moveField(uid: number, delta: number) {
|
||||
const index = indexOf(uid)
|
||||
form.value.fields = moveItem(form.value.fields, index, index + delta)
|
||||
edited()
|
||||
}
|
||||
|
||||
function reorderFields(order: number[]) {
|
||||
const byUid = new Map(form.value.fields.map((field) => [field.uid, field]))
|
||||
form.value.fields = order.map((uid) => byUid.get(uid)!)
|
||||
edited()
|
||||
}
|
||||
|
||||
function setOptions(uid: number, options: string[]) {
|
||||
patchField(uid, { options }, 'choices')
|
||||
}
|
||||
|
||||
function addChoice(uid: number, text: string) {
|
||||
const field = form.value.fields[indexOf(uid)]
|
||||
if (!field || text.trim() === '' || field.options.length >= BUILDER_LIMITS.choicesMax) {
|
||||
return
|
||||
}
|
||||
setOptions(uid, [...field.options, text])
|
||||
}
|
||||
|
||||
function removeChoice(uid: number, index: number) {
|
||||
const field = form.value.fields[indexOf(uid)]
|
||||
if (field) {
|
||||
setOptions(
|
||||
uid,
|
||||
field.options.filter((_, position) => position !== index),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function renameChoice(uid: number, index: number, text: string) {
|
||||
const field = form.value.fields[indexOf(uid)]
|
||||
if (field) {
|
||||
setOptions(
|
||||
uid,
|
||||
field.options.map((option, position) => (position === index ? text : option)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function reorderChoices(uid: number, options: string[]) {
|
||||
setOptions(uid, options)
|
||||
}
|
||||
|
||||
function discard() {
|
||||
form.value = blankForm(newUid())
|
||||
initialSignature = signature(form.value)
|
||||
selectedUid.value = form.value.fields[0]!.uid
|
||||
touched.value = new Set()
|
||||
submitted.value = false
|
||||
keyServerError.value = ''
|
||||
failure.value = 'none'
|
||||
saveState.value = 'editing'
|
||||
}
|
||||
|
||||
function onKeyTaken() {
|
||||
form.value.keyMode = 'manual'
|
||||
keyServerError.value = t(
|
||||
'The key "{0}" is already used by another content type. Choose a different key.',
|
||||
[form.value.key],
|
||||
)
|
||||
keyFocusToken.value++
|
||||
}
|
||||
|
||||
function onInvalidKey() {
|
||||
form.value.keyMode = 'manual'
|
||||
keyServerError.value = t('Use 2 to 40 lowercase letters, digits or hyphens.')
|
||||
keyFocusToken.value++
|
||||
}
|
||||
|
||||
function onRefused(error: unknown) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
const body = error instanceof ApiError ? error.body : undefined
|
||||
if (error instanceof ApiError && error.status === 409 && body === KEY_TAKEN_BODY) {
|
||||
onKeyTaken()
|
||||
} else if (error instanceof ApiError && error.status === 400 && body === INVALID_KEY_BODY) {
|
||||
onInvalidKey()
|
||||
} else if (
|
||||
error instanceof ApiError &&
|
||||
error.status === 422 &&
|
||||
body === INVALID_FIELD_BODY
|
||||
) {
|
||||
failure.value = 'invalid'
|
||||
} else if (error instanceof ApiError && error.status === 403) {
|
||||
failure.value = 'forbidden'
|
||||
} else {
|
||||
failure.value = 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
function announceSaved(name: string, count: number) {
|
||||
toast.add(() => ({
|
||||
id: TOAST_ID,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: t('Content type saved'),
|
||||
description:
|
||||
count === 1
|
||||
? t('{0} now has 1 field.', [name])
|
||||
: t('{0} now has {1} fields.', [name, count]),
|
||||
}))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value && !canReveal.value) {
|
||||
return
|
||||
}
|
||||
submitted.value = true
|
||||
if (!canSave.value) {
|
||||
return
|
||||
}
|
||||
failure.value = 'none'
|
||||
saveState.value = 'saving'
|
||||
let created: ContentType
|
||||
try {
|
||||
created = await useContentTypes().createContentType(buildPayload(form.value))
|
||||
} catch (error) {
|
||||
saveState.value = 'failed'
|
||||
onRefused(error)
|
||||
return
|
||||
}
|
||||
saveState.value = 'saved'
|
||||
store.add(created)
|
||||
announceSaved(created.name, created.fields.length)
|
||||
await router.replace(SCHEMA_PATH).catch(() => undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
selectedUid,
|
||||
selected,
|
||||
dirty,
|
||||
shown,
|
||||
problems,
|
||||
status,
|
||||
canSave,
|
||||
canReveal,
|
||||
canDiscard,
|
||||
saving,
|
||||
failure,
|
||||
keyFocusToken,
|
||||
labelFocusToken,
|
||||
online,
|
||||
setName,
|
||||
setKey,
|
||||
unlockKey,
|
||||
resetKey,
|
||||
setLabel,
|
||||
setFieldKey,
|
||||
unlockFieldKey,
|
||||
resetFieldKey,
|
||||
setFieldType,
|
||||
setLimit,
|
||||
setDefault,
|
||||
patchField,
|
||||
selectField,
|
||||
addField,
|
||||
removeField,
|
||||
moveField,
|
||||
reorderFields,
|
||||
addChoice,
|
||||
removeChoice,
|
||||
renameChoice,
|
||||
reorderChoices,
|
||||
touch,
|
||||
discard,
|
||||
save,
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,26 @@ import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
export type SchemaGateScope = 'all' | 'builtin'
|
||||
export type SchemaGateScope = 'all' | 'builtin' | 'custom'
|
||||
|
||||
export function useSchemaGate(scope: SchemaGateScope) {
|
||||
const session = useSessionStore()
|
||||
const store = useContentTypesStore()
|
||||
const skeleton = useDelayedSkeleton()
|
||||
const withCustom = scope === 'all'
|
||||
const withCustom = scope !== 'builtin'
|
||||
const withBuiltin = scope !== 'custom'
|
||||
|
||||
const sessionReady = computed(() => session.status === 'ready')
|
||||
const forbidden = computed(
|
||||
() => (sessionReady.value && !session.canManageContent) || store.forbidden,
|
||||
() =>
|
||||
(sessionReady.value && !session.canManageContent) ||
|
||||
(withCustom && store.status === 'forbidden') ||
|
||||
(withBuiltin && store.builtinStatus === 'forbidden'),
|
||||
)
|
||||
const loading = computed(
|
||||
() => !forbidden.value && ((withCustom && store.customLoading) || store.builtinLoading),
|
||||
() =>
|
||||
!forbidden.value &&
|
||||
((withCustom && store.customLoading) || (withBuiltin && store.builtinLoading)),
|
||||
)
|
||||
const showSkeleton = computed(() => loading.value && skeleton.visible.value)
|
||||
const customFailed = computed(() => withCustom && store.error)
|
||||
|
||||
@@ -2298,6 +2298,117 @@ export const messages = {
|
||||
'Il a peut-être été supprimé, ou l’adresse est mal saisie.',
|
||||
'GET /v1/builtin-type failed. Check your connection and try again.':
|
||||
'GET /v1/builtin-type a échoué. Vérifiez votre connexion et réessayez.',
|
||||
'{0} now has 1 field.': '{0} a maintenant 1 champ.',
|
||||
'{0} now has {1} fields.': '{0} a maintenant {1} champs.',
|
||||
'{0} problems to fix': '{0} problèmes à corriger',
|
||||
'1 problem to fix': '1 problème à corriger',
|
||||
'A calendar day, without a time': 'Un jour du calendrier, sans heure',
|
||||
'A content type can have up to 50 fields.': 'Un type de contenu peut avoir jusqu’à 50 champs.',
|
||||
'A content type needs at least one field.': 'Un type de contenu a besoin d’au moins un champ.',
|
||||
Add: 'Ajouter',
|
||||
'Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save.':
|
||||
'Ajoutez de 1 à 50 choix. Chacun doit avoir un nom différent. Supprimer un choix utilisé par des entrées sera refusé à l’enregistrement.',
|
||||
'Add a choice': 'Ajouter un choix',
|
||||
'Add a field, then choose its type and rules here.':
|
||||
'Ajoutez un champ, puis choisissez ici son type et ses règles.',
|
||||
'Add at least one choice.': 'Ajoutez au moins un choix.',
|
||||
'Add field': 'Ajouter un champ',
|
||||
'Add the first field.': 'Ajoutez le premier champ.',
|
||||
"A field with unique values can't have a default.":
|
||||
'Un champ à valeurs uniques ne peut pas avoir de valeur par défaut.',
|
||||
"An entry can't be saved with this field empty.":
|
||||
'Une entrée ne peut pas être enregistrée si ce champ est vide.',
|
||||
'An on or off switch': 'Un interrupteur activé ou désactivé',
|
||||
'Another field already uses this key.': 'Un autre champ utilise déjà cette clé.',
|
||||
'API preview': 'Aperçu de l’API',
|
||||
"A sample entry. Values come from each field's type and default.":
|
||||
'Un exemple d’entrée. Les valeurs viennent du type et de la valeur par défaut de chaque champ.',
|
||||
characters: 'caractères',
|
||||
'Check the fields marked in the list.': 'Vérifiez les champs marqués dans la liste.',
|
||||
'Choice {0}': 'Choix {0}',
|
||||
Choices: 'Choix',
|
||||
"Choices can't be blank.": 'Les choix ne peuvent pas être vides.',
|
||||
'Content type saved': 'Type de contenu enregistré',
|
||||
"Couldn't save the content type": 'Impossible d’enregistrer le type de contenu',
|
||||
'Counted in characters. Entries show a counter.':
|
||||
'Compté en caractères. Les entrées affichent un compteur.',
|
||||
'Default value': 'Valeur par défaut',
|
||||
Discard: 'Abandonner',
|
||||
'Each entry needs a different value.': 'Chaque entrée doit avoir une valeur différente.',
|
||||
'Earliest date': 'Date la plus proche',
|
||||
"Earliest date can't be after the latest date.":
|
||||
'La date la plus proche ne peut pas être après la date la plus éloignée.',
|
||||
'Enter a number.': 'Saisissez un nombre.',
|
||||
'Entries outside the range are refused.': 'Les entrées hors de cette plage sont refusées.',
|
||||
'Field: {0}': 'Champ : {0}',
|
||||
'Field settings': 'Réglages du champ',
|
||||
'For example, Events': 'Par exemple, Événements',
|
||||
'For example, Title': 'Par exemple, Titre',
|
||||
'Give the content type a name.': 'Donnez un nom au type de contenu.',
|
||||
'Use 100 characters or fewer.': 'Utilisez 100 caractères maximum.',
|
||||
'Give the field a label.': 'Donnez un libellé au champ.',
|
||||
'Highest value': 'Valeur la plus haute',
|
||||
'Latest date': 'Date la plus éloignée',
|
||||
Limits: 'Limites',
|
||||
'Limits come from the choices below.': 'Les limites viennent des choix ci-dessous.',
|
||||
Longest: 'Plus long',
|
||||
'Long text with formatting, written in Markdown':
|
||||
'Texte long avec mise en forme, écrit en Markdown',
|
||||
'Lowest value': 'Valeur la plus basse',
|
||||
"Lowest value can't be above the highest value.":
|
||||
'La valeur la plus basse ne peut pas dépasser la valeur la plus haute.',
|
||||
'Move {0}': 'Déplacer {0}',
|
||||
'Move down': 'Descendre',
|
||||
'Move up': 'Monter',
|
||||
'Moving {0}. Position {1} of {2}. Press Up or Down to move, Space to drop, Esc to cancel.':
|
||||
'Déplacement de {0}. Position {1} sur {2}. Appuyez sur Haut ou Bas pour déplacer, sur Espace pour déposer, sur Échap pour annuler.',
|
||||
New: 'Nouveau',
|
||||
'New entries start with this switch off when the field is left out.':
|
||||
'Les nouvelles entrées démarrent avec cet interrupteur désactivé quand le champ est omis.',
|
||||
'New entries start with this switch on when the field is left out.':
|
||||
'Les nouvelles entrées démarrent avec cet interrupteur activé quand le champ est omis.',
|
||||
'No default': 'Aucune valeur par défaut',
|
||||
'No fields yet': 'Aucun champ pour l’instant',
|
||||
'No maximum': 'Aucun maximum',
|
||||
'No minimum': 'Aucun minimum',
|
||||
'Not available for rich text.': 'Indisponible pour le texte enrichi.',
|
||||
'Not available for yes or no fields.': 'Indisponible pour les champs oui ou non.',
|
||||
'On by default': 'Activé par défaut',
|
||||
'One option from a list you define': 'Une option parmi une liste que vous définissez',
|
||||
'Reset to match the label': 'Rétablir d’après le libellé',
|
||||
Shortest: 'Plus court',
|
||||
"Shortest can't be longer than longest.":
|
||||
'Le plus court ne peut pas être plus long que le plus long.',
|
||||
'Short text, like a title or a name': 'Texte court, comme un titre ou un nom',
|
||||
'Something went wrong on our side. Your changes are still here. Try again in a moment.':
|
||||
'Un problème est survenu de notre côté. Vos modifications sont toujours là. Réessayez dans un instant.',
|
||||
'Start with a letter. Use lowercase letters, digits and underscores.':
|
||||
'Commencez par une lettre. Utilisez des minuscules, des chiffres et des tirets bas.',
|
||||
'The default value is outside the limits.': 'La valeur par défaut est hors des limites.',
|
||||
'The key "{0}" is already used by another content type. Choose a different key.':
|
||||
'La clé « {0} » est déjà utilisée par un autre type de contenu. Choisissez une autre clé.',
|
||||
'The key "{0}" is reserved. Choose a different key.':
|
||||
'La clé « {0} » est réservée. Choisissez une autre clé.',
|
||||
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.':
|
||||
'Le nom utilisé dans l’API. Commence par une lettre. Minuscules, chiffres et tirets bas.',
|
||||
'Two choices are the same.': 'Deux choix sont identiques.',
|
||||
Unique: 'Unique',
|
||||
'Unique values': 'Valeurs uniques',
|
||||
'Untitled field': 'Champ sans titre',
|
||||
'Updates as you edit. Fields you add or remove here appear after you save.':
|
||||
'Se met à jour pendant la saisie. Les champs que vous ajoutez ou supprimez ici apparaissent après l’enregistrement.',
|
||||
'Use 2 to 40 lowercase letters, digits or hyphens.':
|
||||
'Utilisez de 2 à 40 minuscules, chiffres ou tirets.',
|
||||
'Use a whole number, 0 or more.': 'Utilisez un nombre entier, 0 ou plus.',
|
||||
"Used in URLs and the API. Lowercase letters, digits and hyphens. It can't change after you save.":
|
||||
'Utilisée dans les adresses et l’API. Minuscules, chiffres et tirets. Elle ne peut plus changer après l’enregistrement.',
|
||||
'Used when an entry is created and this field is left out.':
|
||||
'Utilisée quand une entrée est créée et que ce champ est omis.',
|
||||
'Where entries of this type live': 'Où se trouvent les entrées de ce type',
|
||||
'Whole or decimal numbers': 'Nombres entiers ou décimaux',
|
||||
'Yes or no fields have no limits.': 'Les champs oui ou non n’ont pas de limites.',
|
||||
"You're offline. Changes will be saved when you reconnect.":
|
||||
'Vous êtes hors ligne. Les modifications seront enregistrées à la reconnexion.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { moveItem } from '@/lib/arrayMove'
|
||||
|
||||
describe('moveItem', () => {
|
||||
it('moves an item forward and backward without touching the input', () => {
|
||||
const items = ['a', 'b', 'c']
|
||||
expect(moveItem(items, 0, 2)).toEqual(['b', 'c', 'a'])
|
||||
expect(moveItem(items, 2, 0)).toEqual(['c', 'a', 'b'])
|
||||
expect(items).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[1, 1],
|
||||
[-1, 0],
|
||||
[0, -1],
|
||||
[3, 0],
|
||||
[0, 3],
|
||||
])('returns the same array for %i to %i', (from, to) => {
|
||||
const items = ['a', 'b', 'c']
|
||||
expect(moveItem(items, from, to)).toBe(items)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { deriveFieldKey, deriveTypeKey } from '@/lib/schemaBuilderKeys'
|
||||
|
||||
describe('type key from the name', () => {
|
||||
it.each([
|
||||
['Events', 'events'],
|
||||
['Press mentions', 'press-mentions'],
|
||||
['Café & Bar', 'cafe-bar'],
|
||||
[' Job openings! ', 'job-openings'],
|
||||
['2026 Awards', '2026-awards'],
|
||||
['!!!', ''],
|
||||
['', ''],
|
||||
])('%j gives %j', (name, key) => {
|
||||
expect(deriveTypeKey(name)).toBe(key)
|
||||
})
|
||||
|
||||
it('keeps the first 40 characters of a long name', () => {
|
||||
const name = 'abcdefghij'.repeat(6)
|
||||
|
||||
expect(deriveTypeKey(name)).toBe(name.slice(0, 40))
|
||||
})
|
||||
|
||||
it('drops a hyphen left at the end by the cut', () => {
|
||||
const name = `${'a'.repeat(39)} bbbbbbbbbb`
|
||||
|
||||
expect(deriveTypeKey(name)).toBe('a'.repeat(39))
|
||||
})
|
||||
})
|
||||
|
||||
describe('field key from the label', () => {
|
||||
it.each([
|
||||
['Title', 'title'],
|
||||
['Starts on', 'starts_on'],
|
||||
['Éléments', 'elements'],
|
||||
['Ends on (date)', 'ends_on_date'],
|
||||
['2nd room', 'nd_room'],
|
||||
['!!!', ''],
|
||||
['', ''],
|
||||
])('%j gives %j', (label, key) => {
|
||||
expect(deriveFieldKey(label, [])).toBe(key)
|
||||
})
|
||||
|
||||
it('adds _2 when the key already exists, then _3', () => {
|
||||
expect(deriveFieldKey('Title', ['title'])).toBe('title_2')
|
||||
expect(deriveFieldKey('Title', ['title', 'title_2'])).toBe('title_3')
|
||||
})
|
||||
|
||||
it('keeps the first 40 characters of a long label', () => {
|
||||
const label = 'abcdefghij'.repeat(6)
|
||||
|
||||
expect(deriveFieldKey(label, [])).toBe(label.slice(0, 40))
|
||||
})
|
||||
|
||||
it('keeps the suffixed key within 40 characters', () => {
|
||||
const long = 'a'.repeat(40)
|
||||
|
||||
const key = deriveFieldKey(long, [long])
|
||||
|
||||
expect(key).toBe(`${'a'.repeat(38)}_2`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
blankForm,
|
||||
changeFieldType,
|
||||
clearStaleDefault,
|
||||
createField,
|
||||
effectiveDefault,
|
||||
isUnique,
|
||||
keyDisplayMode,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
|
||||
describe('changing the type of a field', () => {
|
||||
const number = createField(7, {
|
||||
label: 'Capacity',
|
||||
key: 'capacity',
|
||||
keyMode: 'manual',
|
||||
type: 'number',
|
||||
required: true,
|
||||
unique: true,
|
||||
defaultValue: '20',
|
||||
min: '1',
|
||||
max: '5000',
|
||||
})
|
||||
|
||||
it('keeps the label, key and Required and resets the rest', () => {
|
||||
const next = changeFieldType(number, 'boolean')
|
||||
|
||||
expect(next).toEqual(
|
||||
createField(7, {
|
||||
label: 'Capacity',
|
||||
key: 'capacity',
|
||||
keyMode: 'manual',
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('starts a Choice field with no choice', () => {
|
||||
expect(changeFieldType(changeFieldType(number, 'boolean'), 'select').options).toEqual([])
|
||||
})
|
||||
|
||||
it('returns the same field for the same type', () => {
|
||||
expect(changeFieldType(number, 'number')).toBe(number)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blank form', () => {
|
||||
it('holds the starter field Title', () => {
|
||||
const form = blankForm(1)
|
||||
|
||||
expect([form.name, form.key, form.keyMode]).toEqual(['', '', 'auto'])
|
||||
expect(form.fields).toHaveLength(1)
|
||||
expect([form.fields[0]!.label, form.fields[0]!.key, form.fields[0]!.type]).toEqual([
|
||||
'Title',
|
||||
'title',
|
||||
'text',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('stale default', () => {
|
||||
it('clears a Choice default that is no longer a choice', () => {
|
||||
const field = createField(1, { type: 'select', options: ['A'], defaultValue: 'B' })
|
||||
|
||||
expect(clearStaleDefault(field).defaultValue).toBe('')
|
||||
})
|
||||
|
||||
it('keeps a default that is still a choice', () => {
|
||||
const field = createField(1, { type: 'select', options: ['A'], defaultValue: 'A' })
|
||||
|
||||
expect(clearStaleDefault(field)).toBe(field)
|
||||
})
|
||||
})
|
||||
|
||||
describe('key display mode', () => {
|
||||
it.each([
|
||||
['manual', '', 'manual'],
|
||||
['manual', 'title', 'manual'],
|
||||
['auto', 'title', 'auto'],
|
||||
['auto', '', 'empty'],
|
||||
] as const)('%s with key %j is %s', (mode, key, expected) => {
|
||||
expect(keyDisplayMode(mode, key)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('effective default', () => {
|
||||
it('is the typed default for a plain field', () => {
|
||||
expect(effectiveDefault(createField(1, { defaultValue: 'x' }))).toBe('x')
|
||||
})
|
||||
|
||||
it('is empty for a unique field that can be unique', () => {
|
||||
const field = createField(1, { defaultValue: 'x', unique: true })
|
||||
expect(isUnique(field)).toBe(true)
|
||||
expect(effectiveDefault(field)).toBe('')
|
||||
})
|
||||
|
||||
it('ignores unique on a type that cannot be unique', () => {
|
||||
const field = createField(1, { type: 'richtext', defaultValue: 'x', unique: true })
|
||||
expect(isUnique(field)).toBe(false)
|
||||
expect(effectiveDefault(field)).toBe('x')
|
||||
})
|
||||
|
||||
it('keeps a select default only while it is one of the choices', () => {
|
||||
const field = createField(1, { type: 'select', options: [' Talk '], defaultValue: 'Talk' })
|
||||
expect(effectiveDefault(field)).toBe('Talk')
|
||||
expect(effectiveDefault({ ...field, defaultValue: 'Other' })).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createField, type BuilderForm } from '@/lib/schemaBuilderModel'
|
||||
import { buildPayload } from '@/lib/schemaBuilderPayload'
|
||||
|
||||
const EVENTS: BuilderForm = {
|
||||
name: ' Events ',
|
||||
key: 'events',
|
||||
keyMode: 'auto',
|
||||
fields: [
|
||||
createField(1, { label: 'Title', key: 'title', required: true, unique: true, max: '80' }),
|
||||
createField(2, {
|
||||
label: 'Starts on',
|
||||
key: 'starts_on',
|
||||
type: 'date',
|
||||
required: true,
|
||||
min: '2026-01-01',
|
||||
max: '2027-12-31',
|
||||
}),
|
||||
createField(3, { label: 'Online', key: 'online', type: 'boolean', defaultOn: true }),
|
||||
createField(4, {
|
||||
label: 'Capacity',
|
||||
key: 'capacity',
|
||||
type: 'number',
|
||||
defaultValue: '20',
|
||||
min: '1',
|
||||
max: '5000',
|
||||
}),
|
||||
createField(5, {
|
||||
label: 'Category',
|
||||
key: 'category',
|
||||
type: 'select',
|
||||
defaultValue: 'Talk',
|
||||
options: [' Talk', 'Workshop ', 'Meetup'],
|
||||
}),
|
||||
createField(6, { label: 'Description', key: 'description', type: 'richtext', max: '5000' }),
|
||||
],
|
||||
}
|
||||
|
||||
function field(key: string, rest: object) {
|
||||
return { key, label: key, type: 'text', required: false, unique: false, ...rest }
|
||||
}
|
||||
|
||||
describe('create payload', () => {
|
||||
it('builds the exact body for the six-field Events form', () => {
|
||||
expect(buildPayload(EVENTS)).toEqual({
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
fields: [
|
||||
{
|
||||
...field('title', {}),
|
||||
label: 'Title',
|
||||
required: true,
|
||||
unique: true,
|
||||
default: null,
|
||||
min: null,
|
||||
max: 80,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'starts_on',
|
||||
label: 'Starts on',
|
||||
type: 'date',
|
||||
required: true,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: '2026-01-01',
|
||||
max: '2027-12-31',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'online',
|
||||
label: 'Online',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: true,
|
||||
min: null,
|
||||
max: null,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'capacity',
|
||||
label: 'Capacity',
|
||||
type: 'number',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: 20,
|
||||
min: 1,
|
||||
max: 5000,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: 'Category',
|
||||
type: 'select',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: 'Talk',
|
||||
min: null,
|
||||
max: null,
|
||||
options: ['Talk', 'Workshop', 'Meetup'],
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
type: 'richtext',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: 5000,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('has no icon key and exactly the nine field keys', () => {
|
||||
const body = buildPayload(EVENTS)
|
||||
|
||||
expect(Object.keys(body).sort()).toEqual(['fields', 'key', 'name'])
|
||||
for (const sent of body.fields) {
|
||||
expect(Object.keys(sent).sort()).toEqual([
|
||||
'default',
|
||||
'key',
|
||||
'label',
|
||||
'max',
|
||||
'min',
|
||||
'options',
|
||||
'required',
|
||||
'type',
|
||||
'unique',
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('sends null for an off switch', () => {
|
||||
const body = buildPayload({
|
||||
...EVENTS,
|
||||
fields: [createField(1, { label: 'On', key: 'on', type: 'boolean' })],
|
||||
})
|
||||
|
||||
expect(body.fields[0]!.default).toBeNull()
|
||||
})
|
||||
|
||||
it('does not send the default of a unique field', () => {
|
||||
const body = buildPayload({
|
||||
...EVENTS,
|
||||
fields: [createField(1, { label: 'T', key: 't', unique: true, defaultValue: 'Draft' })],
|
||||
})
|
||||
|
||||
expect(body.fields[0]!.default).toBeNull()
|
||||
})
|
||||
|
||||
it('sends no unique flag for rich text and yes or no fields', () => {
|
||||
const body = buildPayload({
|
||||
...EVENTS,
|
||||
fields: [
|
||||
createField(1, { label: 'R', key: 'r', type: 'richtext', unique: true }),
|
||||
createField(2, { label: 'B', key: 'b', type: 'boolean', unique: true }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(body.fields.map((sent) => sent.unique)).toEqual([false, false])
|
||||
})
|
||||
|
||||
it('sends no limits for yes or no and choice fields', () => {
|
||||
const body = buildPayload({
|
||||
...EVENTS,
|
||||
fields: [
|
||||
createField(1, { label: 'B', key: 'b', type: 'boolean', min: '1', max: '2' }),
|
||||
createField(2, { label: 'S', key: 's', type: 'select', min: '1', options: ['A'] }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(body.fields.map((sent) => [sent.min, sent.max])).toEqual([
|
||||
[null, null],
|
||||
[null, null],
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a limit of zero', () => {
|
||||
const body = buildPayload({
|
||||
...EVENTS,
|
||||
fields: [createField(1, { label: 'T', key: 't', min: '0' })],
|
||||
})
|
||||
|
||||
expect(body.fields[0]!.min).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createField, type BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import { previewRoutes, sampleEntry, sampleJson } from '@/lib/schemaBuilderSample'
|
||||
|
||||
function valueOf(overrides: Partial<BuilderField>) {
|
||||
const field = createField(1, { label: 'F', key: 'f', ...overrides })
|
||||
return sampleEntry('events', [field]).data.f
|
||||
}
|
||||
|
||||
describe('sample values', () => {
|
||||
it.each([
|
||||
['text', {}, 'Example text'],
|
||||
['text', { defaultValue: 'Draft' }, 'Draft'],
|
||||
['richtext', {}, 'Markdown text'],
|
||||
['number', {}, 0],
|
||||
['number', { min: '1' }, 1],
|
||||
['number', { min: '1', defaultValue: '20' }, 20],
|
||||
['boolean', {}, false],
|
||||
['boolean', { defaultOn: true }, true],
|
||||
['date', {}, '2026-11-21'],
|
||||
['select', { options: ['Talk', 'Workshop'] }, 'Talk'],
|
||||
['select', { options: ['Talk', 'Workshop'], defaultValue: 'Workshop' }, 'Workshop'],
|
||||
['select', {}, null],
|
||||
] as const)('%s with %j holds %j', (type, settings, expected) => {
|
||||
expect(valueOf({ type, ...settings } as Partial<BuilderField>)).toBe(expected)
|
||||
})
|
||||
|
||||
it('ignores the default of a unique field', () => {
|
||||
expect(valueOf({ unique: true, defaultValue: 'Draft' })).toBe('Example text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sample entry', () => {
|
||||
const fields = [
|
||||
createField(1, { label: 'Title', key: 'title' }),
|
||||
createField(2, { label: 'Bad', key: 'start-date', keyMode: 'manual' }),
|
||||
createField(3, { label: 'Online', key: 'online', type: 'boolean' }),
|
||||
]
|
||||
|
||||
it('wraps the data in the fixed envelope, in field order, skipping invalid keys', () => {
|
||||
expect(sampleEntry('events', fields)).toEqual({
|
||||
id: 42,
|
||||
content_type: 'events',
|
||||
data: { title: 'Example text', online: false },
|
||||
created_by: 3,
|
||||
updated_by: 3,
|
||||
created_at: '2026-10-15T11:02:00Z',
|
||||
updated_at: '2026-10-15T11:02:00Z',
|
||||
})
|
||||
expect(Object.keys(sampleEntry('events', fields).data)).toEqual(['title', 'online'])
|
||||
})
|
||||
|
||||
it('indents by two spaces', () => {
|
||||
expect(sampleJson('events', fields.slice(0, 1))).toBe(
|
||||
JSON.stringify(sampleEntry('events', fields.slice(0, 1)), null, 2),
|
||||
)
|
||||
expect(sampleJson('events', fields.slice(0, 1))).toContain('\n "id": 42,')
|
||||
})
|
||||
|
||||
it('falls back to the key events while the key is empty', () => {
|
||||
expect(sampleEntry('', []).content_type).toBe('events')
|
||||
expect(previewRoutes('')[0]!.path).toBe('/v1/content-type/events/entry')
|
||||
})
|
||||
|
||||
it('lists the five routes of the type', () => {
|
||||
expect(previewRoutes('recipes')).toEqual([
|
||||
{ method: 'GET', path: '/v1/content-type/recipes/entry' },
|
||||
{ method: 'POST', path: '/v1/content-type/recipes/entry' },
|
||||
{ method: 'GET', path: '/v1/content-type/recipes/entry/{id}' },
|
||||
{ method: 'PUT', path: '/v1/content-type/recipes/entry/{id}' },
|
||||
{ method: 'DELETE', path: '/v1/content-type/recipes/entry/{id}' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FIELD_ERROR_TOUCH, fieldTouchKey, typeTouchKey } from '@/lib/schemaBuilderTouch'
|
||||
|
||||
describe('touch keys', () => {
|
||||
it('keeps type and field keys apart', () => {
|
||||
expect(typeTouchKey('key')).toBe('type:key')
|
||||
expect(fieldTouchKey(3, 'key')).toBe('field:3:key')
|
||||
expect(fieldTouchKey(3, 'key')).not.toBe(fieldTouchKey(33, 'key'))
|
||||
expect(fieldTouchKey(1, 'label')).not.toBe(fieldTouchKey(1, 'key'))
|
||||
})
|
||||
|
||||
it('maps both choice errors to the choices touch', () => {
|
||||
const choices = FIELD_ERROR_TOUCH.filter(([, touch]) => touch === 'choices').map(
|
||||
([error]) => error,
|
||||
)
|
||||
expect(choices).toEqual(['options', 'choices'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createField,
|
||||
starterField,
|
||||
type BuilderField,
|
||||
type BuilderForm,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
import {
|
||||
countProblems,
|
||||
validateField,
|
||||
validateForm,
|
||||
validateType,
|
||||
} from '@/lib/schemaBuilderValidation'
|
||||
|
||||
const KEY_MESSAGE = 'Use 2 to 40 lowercase letters, digits or hyphens.'
|
||||
|
||||
function form(overrides: Partial<BuilderForm> = {}): BuilderForm {
|
||||
return {
|
||||
name: 'Events',
|
||||
key: 'events',
|
||||
keyMode: 'auto',
|
||||
fields: [starterField(1)],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function field(overrides: Partial<BuilderField> = {}) {
|
||||
return createField(2, { label: 'Thing', key: 'thing', ...overrides })
|
||||
}
|
||||
|
||||
describe('type validation', () => {
|
||||
it.each([
|
||||
['', 'Give the content type a name.'],
|
||||
[' ', 'Give the content type a name.'],
|
||||
['a'.repeat(101), 'Use 100 characters or fewer.'],
|
||||
['a'.repeat(100), undefined],
|
||||
['Events', undefined],
|
||||
])('name %j', (name, message) => {
|
||||
expect(validateType(form({ name })).name).toBe(message)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['e', KEY_MESSAGE],
|
||||
['Events', KEY_MESSAGE],
|
||||
['my_type', KEY_MESSAGE],
|
||||
['-events', KEY_MESSAGE],
|
||||
['events-', KEY_MESSAGE],
|
||||
['events--2', KEY_MESSAGE],
|
||||
['', KEY_MESSAGE],
|
||||
['a'.repeat(41), KEY_MESSAGE],
|
||||
['new', 'The key "new" is reserved. Choose a different key.'],
|
||||
['ab', undefined],
|
||||
['press-mentions', undefined],
|
||||
['2026-awards', undefined],
|
||||
['a'.repeat(40), undefined],
|
||||
])('key %j', (key, message) => {
|
||||
expect(validateType(form({ key })).key).toBe(message)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0, 'A content type needs at least one field.'],
|
||||
[1, undefined],
|
||||
[50, undefined],
|
||||
[51, 'A content type can have up to 50 fields.'],
|
||||
])('%i fields', (count, message) => {
|
||||
const fields = Array.from({ length: count }, (_, index) => starterField(index))
|
||||
|
||||
expect(validateType(form({ fields })).fields).toBe(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('field validation', () => {
|
||||
const KEY_RULE = 'Start with a letter. Use lowercase letters, digits and underscores.'
|
||||
|
||||
it.each([
|
||||
['', 'Give the field a label.'],
|
||||
[' ', 'Give the field a label.'],
|
||||
['Title', undefined],
|
||||
])('label %j', (label, message) => {
|
||||
expect(validateField(field({ label }), []).label).toBe(message)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Title', KEY_RULE],
|
||||
['1st', KEY_RULE],
|
||||
['a-b', KEY_RULE],
|
||||
['a b', KEY_RULE],
|
||||
['', KEY_RULE],
|
||||
['_a', KEY_RULE],
|
||||
['a'.repeat(41), KEY_RULE],
|
||||
['starts_on', undefined],
|
||||
['a', undefined],
|
||||
['a'.repeat(40), undefined],
|
||||
])('manual key %j', (key, message) => {
|
||||
expect(validateField(field({ key, keyMode: 'manual' }), []).key).toBe(message)
|
||||
})
|
||||
|
||||
it('leaves the key error to the label while an automatic key follows a blank label', () => {
|
||||
const errors = validateField(field({ label: '', key: '' }), [])
|
||||
|
||||
expect(errors.key).toBeUndefined()
|
||||
expect(errors.label).toBe('Give the field a label.')
|
||||
})
|
||||
|
||||
it('flags a key already used by an earlier field', () => {
|
||||
expect(validateField(field({ key: 'title' }), ['title']).key).toBe(
|
||||
'Another field already uses this key.',
|
||||
)
|
||||
})
|
||||
|
||||
it('flags only the second of two equal keys', () => {
|
||||
const first = createField(1, { label: 'A', key: 'title', keyMode: 'manual' })
|
||||
const second = createField(2, { label: 'B', key: 'title', keyMode: 'manual' })
|
||||
|
||||
const errors = validateForm(form({ fields: [first, second] }))
|
||||
|
||||
expect([...errors.fields.keys()]).toEqual([2])
|
||||
expect(errors.fields.get(2)!.key).toBe('Another field already uses this key.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'number',
|
||||
{ min: '6000', max: '5000' },
|
||||
'min',
|
||||
"Lowest value can't be above the highest value.",
|
||||
],
|
||||
['text', { min: '10', max: '3' }, 'min', "Shortest can't be longer than longest."],
|
||||
[
|
||||
'date',
|
||||
{ min: '2027-12-31', max: '2026-01-01' },
|
||||
'min',
|
||||
"Earliest date can't be after the latest date.",
|
||||
],
|
||||
['text', { min: '2.5' }, 'min', 'Use a whole number, 0 or more.'],
|
||||
['text', { min: '-1' }, 'min', 'Use a whole number, 0 or more.'],
|
||||
['richtext', { max: 'abc' }, 'max', 'Use a whole number, 0 or more.'],
|
||||
[
|
||||
'number',
|
||||
{ min: '1', max: '5000', defaultValue: '0' },
|
||||
'default',
|
||||
'The default value is outside the limits.',
|
||||
],
|
||||
[
|
||||
'number',
|
||||
{ max: '10', defaultValue: '11' },
|
||||
'default',
|
||||
'The default value is outside the limits.',
|
||||
],
|
||||
['number', { defaultValue: 'x' }, 'default', 'Enter a number.'],
|
||||
[
|
||||
'text',
|
||||
{ max: '3', defaultValue: 'abcd' },
|
||||
'default',
|
||||
'The default value is outside the limits.',
|
||||
],
|
||||
[
|
||||
'text',
|
||||
{ min: '3', defaultValue: 'ab' },
|
||||
'default',
|
||||
'The default value is outside the limits.',
|
||||
],
|
||||
[
|
||||
'date',
|
||||
{ min: '2026-01-01', max: '2027-12-31', defaultValue: '2028-01-01' },
|
||||
'default',
|
||||
'The default value is outside the limits.',
|
||||
],
|
||||
['select', { options: [] }, 'options', 'Add at least one choice.'],
|
||||
['number', { min: '1', max: '5000' }, 'min', undefined],
|
||||
['number', { min: '5', max: '5' }, 'min', undefined],
|
||||
['text', { min: '0' }, 'min', undefined],
|
||||
['number', { min: '1', max: '5000', defaultValue: '20' }, 'default', undefined],
|
||||
['text', { max: '3', defaultValue: 'abc' }, 'default', undefined],
|
||||
] as const)('%s with %j reports %s as %j', (type, settings, where, message) => {
|
||||
const errors = validateField(field({ type, ...settings } as Partial<BuilderField>), [])
|
||||
|
||||
expect(errors[where]).toBe(message)
|
||||
})
|
||||
|
||||
it('skips the default check while unique values drop the default', () => {
|
||||
const errors = validateField(field({ max: '3', defaultValue: 'abcd', unique: true }), [])
|
||||
|
||||
expect(errors.default).toBeUndefined()
|
||||
})
|
||||
|
||||
it('flags a blank choice and a duplicate once per row, after trimming, case-sensitively', () => {
|
||||
const errors = validateField(
|
||||
field({
|
||||
type: 'select',
|
||||
options: ['Talk', 'Workshop', 'Meetup', ' ', ' Talk ', 'talk'],
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
expect(errors.choices).toEqual({
|
||||
3: "Choices can't be blank.",
|
||||
4: 'Two choices are the same.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('problem counting', () => {
|
||||
it('counts the three type-level inputs', () => {
|
||||
const errors = validateForm(form({ name: '', key: 'e', fields: [] }))
|
||||
|
||||
expect(countProblems(errors)).toBe(3)
|
||||
})
|
||||
|
||||
it('counts a field once whatever the number of rules it breaks', () => {
|
||||
const noLabel = createField(1, { label: '', key: '' })
|
||||
const inverted = createField(2, {
|
||||
label: 'N',
|
||||
key: 'n',
|
||||
type: 'number',
|
||||
min: '6',
|
||||
max: '5',
|
||||
})
|
||||
const choices = createField(3, {
|
||||
label: 'C',
|
||||
key: 'c',
|
||||
type: 'select',
|
||||
options: ['A', '', 'A'],
|
||||
})
|
||||
|
||||
expect(countProblems(validateForm(form({ fields: [noLabel, inverted, choices] })))).toBe(3)
|
||||
})
|
||||
|
||||
it('counts one choice list with a blank and a duplicate as one problem', () => {
|
||||
const choices = createField(1, {
|
||||
label: 'C',
|
||||
key: 'c',
|
||||
type: 'select',
|
||||
options: ['A', '', 'A'],
|
||||
})
|
||||
|
||||
expect(countProblems(validateForm(form({ fields: [choices] })))).toBe(1)
|
||||
})
|
||||
|
||||
it('counts one field with a bad key as one problem', () => {
|
||||
const bad = createField(1, { label: 'S', key: 'start-date', keyMode: 'manual' })
|
||||
|
||||
expect(countProblems(validateForm(form({ fields: [bad] })))).toBe(1)
|
||||
})
|
||||
|
||||
it('counts nothing for a valid form', () => {
|
||||
expect(countProblems(validateForm(form()))).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
export function moveItem<T>(items: T[], from: number, to: number) {
|
||||
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {
|
||||
return items
|
||||
}
|
||||
const next = [...items]
|
||||
const [moved] = next.splice(from, 1)
|
||||
next.splice(to, 0, moved!)
|
||||
return next
|
||||
}
|
||||
@@ -14,16 +14,6 @@ export function metaSummary(authors: Byline[], siteName: string | null) {
|
||||
return siteName ? t('{0} · {1}', [siteName, summary]) : summary
|
||||
}
|
||||
|
||||
export function moveItem<T>(items: T[], from: number, to: number) {
|
||||
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {
|
||||
return items
|
||||
}
|
||||
const next = [...items]
|
||||
const [moved] = next.splice(from, 1)
|
||||
next.splice(to, 0, moved!)
|
||||
return next
|
||||
}
|
||||
|
||||
export function insertionTarget(from: number, gap: number) {
|
||||
return gap > from ? gap - 1 : gap
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const HOME_PATH = '/home'
|
||||
export const SCHEMA_PATH = '/schema'
|
||||
@@ -0,0 +1,35 @@
|
||||
import { BUILDER_LIMITS } from '@/lib/schemaBuilderModel'
|
||||
|
||||
function plain(text: string) {
|
||||
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
|
||||
}
|
||||
|
||||
export function deriveTypeKey(name: string) {
|
||||
const slug = plain(name)
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return slug.slice(0, BUILDER_LIMITS.typeKeyMax).replace(/-+$/, '')
|
||||
}
|
||||
|
||||
function fieldKeyBase(label: string) {
|
||||
return plain(label)
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^[^a-z]+/, '')
|
||||
.slice(0, BUILDER_LIMITS.fieldKeyMax)
|
||||
.replace(/_+$/, '')
|
||||
}
|
||||
|
||||
export function deriveFieldKey(label: string, taken: readonly string[]) {
|
||||
const base = fieldKeyBase(label)
|
||||
if (!base || !taken.includes(base)) {
|
||||
return base
|
||||
}
|
||||
for (let attempt = 2; ; attempt++) {
|
||||
const suffix = `_${attempt}`
|
||||
const candidate =
|
||||
base.slice(0, BUILDER_LIMITS.fieldKeyMax - suffix.length).replace(/_+$/, '') + suffix
|
||||
if (!taken.includes(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
export const FIELD_TYPES = ['text', 'richtext', 'number', 'boolean', 'date', 'select'] as const
|
||||
|
||||
export type FieldType = (typeof FIELD_TYPES)[number]
|
||||
export type KeyMode = 'auto' | 'manual'
|
||||
export type KeyDisplayMode = KeyMode | 'empty'
|
||||
export type BuilderStatus = 'saving' | 'offline' | 'failed' | 'problems' | 'dirty' | 'none'
|
||||
|
||||
export const BUILDER_LIMITS = {
|
||||
nameMax: 100,
|
||||
typeKeyMin: 2,
|
||||
typeKeyMax: 40,
|
||||
fieldsMax: 50,
|
||||
fieldKeyMax: 40,
|
||||
choicesMax: 50,
|
||||
}
|
||||
|
||||
export const RESERVED_TYPE_KEYS = ['new']
|
||||
export const PREVIEW_KEY_FALLBACK = 'events'
|
||||
export const STARTER_LABEL = 'Title'
|
||||
export const STARTER_KEY = 'title'
|
||||
|
||||
export interface BuilderField {
|
||||
uid: number
|
||||
label: string
|
||||
key: string
|
||||
keyMode: KeyMode
|
||||
type: FieldType
|
||||
required: boolean
|
||||
unique: boolean
|
||||
defaultValue: string
|
||||
defaultOn: boolean
|
||||
min: string
|
||||
max: string
|
||||
options: string[]
|
||||
}
|
||||
|
||||
export interface BuilderForm {
|
||||
name: string
|
||||
key: string
|
||||
keyMode: KeyMode
|
||||
fields: BuilderField[]
|
||||
}
|
||||
|
||||
export function keyDisplayMode(mode: KeyMode, key: string): KeyDisplayMode {
|
||||
if (mode === 'manual') {
|
||||
return 'manual'
|
||||
}
|
||||
return key ? 'auto' : 'empty'
|
||||
}
|
||||
|
||||
export function createField(uid: number, overrides: Partial<BuilderField> = {}): BuilderField {
|
||||
return {
|
||||
uid,
|
||||
label: '',
|
||||
key: '',
|
||||
keyMode: 'auto',
|
||||
type: 'text',
|
||||
required: false,
|
||||
unique: false,
|
||||
defaultValue: '',
|
||||
defaultOn: false,
|
||||
min: '',
|
||||
max: '',
|
||||
options: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function starterField(uid: number): BuilderField {
|
||||
return createField(uid, { label: STARTER_LABEL, key: STARTER_KEY })
|
||||
}
|
||||
|
||||
export function blankForm(uid: number): BuilderForm {
|
||||
return { name: '', key: '', keyMode: 'auto', fields: [starterField(uid)] }
|
||||
}
|
||||
|
||||
export function changeFieldType(field: BuilderField, type: FieldType): BuilderField {
|
||||
if (field.type === type) {
|
||||
return field
|
||||
}
|
||||
return createField(field.uid, {
|
||||
label: field.label,
|
||||
key: field.key,
|
||||
keyMode: field.keyMode,
|
||||
required: field.required,
|
||||
type,
|
||||
})
|
||||
}
|
||||
|
||||
export function supportsUnique(type: FieldType) {
|
||||
return type !== 'richtext' && type !== 'boolean'
|
||||
}
|
||||
|
||||
export function supportsLimits(type: FieldType) {
|
||||
return type === 'text' || type === 'richtext' || type === 'number' || type === 'date'
|
||||
}
|
||||
|
||||
export function hasDefaultValue(field: BuilderField) {
|
||||
return !field.unique && field.defaultValue !== ''
|
||||
}
|
||||
|
||||
export function trimmedOptions(field: BuilderField) {
|
||||
return field.options.map((option) => option.trim())
|
||||
}
|
||||
|
||||
export function clearStaleDefault(field: BuilderField): BuilderField {
|
||||
if (field.type !== 'select' || field.defaultValue === '') {
|
||||
return field
|
||||
}
|
||||
return trimmedOptions(field).includes(field.defaultValue)
|
||||
? field
|
||||
: { ...field, defaultValue: '' }
|
||||
}
|
||||
|
||||
export function isUnique(field: BuilderField) {
|
||||
return field.unique && supportsUnique(field.type)
|
||||
}
|
||||
|
||||
export function effectiveDefault(field: BuilderField) {
|
||||
if (isUnique(field) || field.defaultValue === '') {
|
||||
return ''
|
||||
}
|
||||
if (field.type === 'select') {
|
||||
return trimmedOptions(field).includes(field.defaultValue) ? field.defaultValue : ''
|
||||
}
|
||||
return field.defaultValue
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
effectiveDefault,
|
||||
isUnique,
|
||||
supportsLimits,
|
||||
trimmedOptions,
|
||||
type BuilderField,
|
||||
type BuilderForm,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
|
||||
export interface PayloadField {
|
||||
key: string
|
||||
label: string
|
||||
type: string
|
||||
required: boolean
|
||||
unique: boolean
|
||||
default: string | number | boolean | null
|
||||
min: number | string | null
|
||||
max: number | string | null
|
||||
options: string[]
|
||||
}
|
||||
|
||||
export interface ContentTypePayload {
|
||||
key: string
|
||||
name: string
|
||||
fields: PayloadField[]
|
||||
}
|
||||
|
||||
function limit(field: BuilderField, text: string) {
|
||||
if (!supportsLimits(field.type) || text.trim() === '') {
|
||||
return null
|
||||
}
|
||||
return field.type === 'date' ? text : Number(text)
|
||||
}
|
||||
|
||||
function defaultFor(field: BuilderField) {
|
||||
if (field.type === 'boolean') {
|
||||
return field.defaultOn ? true : null
|
||||
}
|
||||
const given = effectiveDefault(field)
|
||||
if (given === '') {
|
||||
return null
|
||||
}
|
||||
return field.type === 'number' ? Number(given) : given
|
||||
}
|
||||
|
||||
export function payloadField(field: BuilderField): PayloadField {
|
||||
return {
|
||||
key: field.key,
|
||||
label: field.label.trim(),
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
unique: isUnique(field),
|
||||
default: defaultFor(field),
|
||||
min: limit(field, field.min),
|
||||
max: limit(field, field.max),
|
||||
options: field.type === 'select' ? trimmedOptions(field) : [],
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPayload(form: BuilderForm): ContentTypePayload {
|
||||
return {
|
||||
key: form.key,
|
||||
name: form.name.trim(),
|
||||
fields: form.fields.map(payloadField),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
PREVIEW_KEY_FALLBACK,
|
||||
effectiveDefault,
|
||||
trimmedOptions,
|
||||
type BuilderField,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
import { isValidFieldKey } from '@/lib/schemaBuilderValidation'
|
||||
|
||||
const SAMPLE_TEXT = 'Example text'
|
||||
const SAMPLE_MARKDOWN = 'Markdown text'
|
||||
const SAMPLE_DATE = '2026-11-21'
|
||||
const SAMPLE_ID = 42
|
||||
const SAMPLE_USER = 3
|
||||
const SAMPLE_MOMENT = '2026-10-15T11:02:00Z'
|
||||
const INDENT = 2
|
||||
|
||||
export function previewKey(key: string) {
|
||||
return key === '' ? PREVIEW_KEY_FALLBACK : key
|
||||
}
|
||||
|
||||
export function previewRoutes(key: string) {
|
||||
const base = `/v1/content-type/${previewKey(key)}/entry`
|
||||
const item = `${base}/{id}`
|
||||
return [
|
||||
{ method: 'GET', path: base },
|
||||
{ method: 'POST', path: base },
|
||||
{ method: 'GET', path: item },
|
||||
{ method: 'PUT', path: item },
|
||||
{ method: 'DELETE', path: item },
|
||||
]
|
||||
}
|
||||
|
||||
export function sampleValue(field: BuilderField): string | number | boolean | null {
|
||||
const given = effectiveDefault(field)
|
||||
switch (field.type) {
|
||||
case 'richtext':
|
||||
return given === '' ? SAMPLE_MARKDOWN : given
|
||||
case 'number': {
|
||||
const fromDefault = Number(given)
|
||||
const fromMin = Number(field.min)
|
||||
if (given !== '' && Number.isFinite(fromDefault)) {
|
||||
return fromDefault
|
||||
}
|
||||
return field.min.trim() !== '' && Number.isFinite(fromMin) ? fromMin : 0
|
||||
}
|
||||
case 'boolean':
|
||||
return field.defaultOn
|
||||
case 'date':
|
||||
return given === '' ? SAMPLE_DATE : given
|
||||
case 'select': {
|
||||
const choices = trimmedOptions(field).filter((option) => option !== '')
|
||||
return given !== '' ? given : (choices[0] ?? null)
|
||||
}
|
||||
default:
|
||||
return given === '' ? SAMPLE_TEXT : given
|
||||
}
|
||||
}
|
||||
|
||||
export function sampleEntry(key: string, fields: readonly BuilderField[]) {
|
||||
const data: Record<string, string | number | boolean | null> = {}
|
||||
for (const field of fields) {
|
||||
if (isValidFieldKey(field.key)) {
|
||||
data[field.key] = sampleValue(field)
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: SAMPLE_ID,
|
||||
content_type: previewKey(key),
|
||||
data,
|
||||
created_by: SAMPLE_USER,
|
||||
updated_by: SAMPLE_USER,
|
||||
created_at: SAMPLE_MOMENT,
|
||||
updated_at: SAMPLE_MOMENT,
|
||||
}
|
||||
}
|
||||
|
||||
export function sampleJson(key: string, fields: readonly BuilderField[]) {
|
||||
return JSON.stringify(sampleEntry(key, fields), null, INDENT)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
|
||||
|
||||
export type TypeTouch = 'name' | 'key' | 'fields'
|
||||
export type FieldTouch = 'label' | 'key' | 'min' | 'max' | 'default' | 'choices'
|
||||
|
||||
export function typeTouchKey(name: TypeTouch) {
|
||||
return `type:${name}`
|
||||
}
|
||||
|
||||
export function fieldTouchKey(uid: number, name: FieldTouch) {
|
||||
return `field:${uid}:${name}`
|
||||
}
|
||||
|
||||
export const FIELD_ERROR_TOUCH: readonly (readonly [keyof FieldErrors, FieldTouch])[] = [
|
||||
['label', 'label'],
|
||||
['key', 'key'],
|
||||
['min', 'min'],
|
||||
['max', 'max'],
|
||||
['default', 'default'],
|
||||
['options', 'choices'],
|
||||
['choices', 'choices'],
|
||||
]
|
||||
@@ -0,0 +1,227 @@
|
||||
import { t } from '@/i18n'
|
||||
import {
|
||||
BUILDER_LIMITS,
|
||||
RESERVED_TYPE_KEYS,
|
||||
trimmedOptions,
|
||||
type BuilderField,
|
||||
type BuilderForm,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
|
||||
const TYPE_KEY_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
||||
const FIELD_KEY_PATTERN = /^[a-z][a-z0-9_]*$/
|
||||
const WHOLE_PATTERN = /^\d+$/
|
||||
|
||||
export interface TypeErrors {
|
||||
name?: string
|
||||
key?: string
|
||||
fields?: string
|
||||
}
|
||||
|
||||
export interface FieldErrors {
|
||||
label?: string
|
||||
key?: string
|
||||
min?: string
|
||||
max?: string
|
||||
default?: string
|
||||
options?: string
|
||||
choices?: Record<number, string>
|
||||
}
|
||||
|
||||
export interface FormErrors {
|
||||
type: TypeErrors
|
||||
fields: Map<number, FieldErrors>
|
||||
}
|
||||
|
||||
export function isValidTypeKey(key: string) {
|
||||
return (
|
||||
key.length >= BUILDER_LIMITS.typeKeyMin &&
|
||||
key.length <= BUILDER_LIMITS.typeKeyMax &&
|
||||
TYPE_KEY_PATTERN.test(key)
|
||||
)
|
||||
}
|
||||
|
||||
export function isValidFieldKey(key: string) {
|
||||
return key.length <= BUILDER_LIMITS.fieldKeyMax && FIELD_KEY_PATTERN.test(key)
|
||||
}
|
||||
|
||||
export function validateType(form: BuilderForm): TypeErrors {
|
||||
const errors: TypeErrors = {}
|
||||
if (form.name.trim() === '') {
|
||||
errors.name = t('Give the content type a name.')
|
||||
} else if (form.name.length > BUILDER_LIMITS.nameMax) {
|
||||
errors.name = t('Use 100 characters or fewer.')
|
||||
}
|
||||
if (RESERVED_TYPE_KEYS.includes(form.key)) {
|
||||
errors.key = t('The key "{0}" is reserved. Choose a different key.', [form.key])
|
||||
} else if (!isValidTypeKey(form.key)) {
|
||||
errors.key = t('Use 2 to 40 lowercase letters, digits or hyphens.')
|
||||
}
|
||||
if (form.fields.length === 0) {
|
||||
errors.fields = t('A content type needs at least one field.')
|
||||
} else if (form.fields.length > BUILDER_LIMITS.fieldsMax) {
|
||||
errors.fields = t('A content type can have up to 50 fields.')
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
function parseWhole(text: string) {
|
||||
return WHOLE_PATTERN.test(text.trim()) ? Number(text.trim()) : null
|
||||
}
|
||||
|
||||
function parseNumber(text: string) {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') {
|
||||
return null
|
||||
}
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) ? parsed : Number.NaN
|
||||
}
|
||||
|
||||
function textLimits(field: BuilderField, errors: FieldErrors) {
|
||||
let min: number | null = null
|
||||
let max: number | null = null
|
||||
if (field.min.trim() !== '') {
|
||||
min = parseWhole(field.min)
|
||||
if (min === null) {
|
||||
errors.min = t('Use a whole number, 0 or more.')
|
||||
}
|
||||
}
|
||||
if (field.max.trim() !== '') {
|
||||
max = parseWhole(field.max)
|
||||
if (max === null) {
|
||||
errors.max = t('Use a whole number, 0 or more.')
|
||||
}
|
||||
}
|
||||
if (min !== null && max !== null && min > max) {
|
||||
errors.min = t("Shortest can't be longer than longest.")
|
||||
}
|
||||
if (field.defaultValue !== '' && !field.unique) {
|
||||
const length = Array.from(field.defaultValue).length
|
||||
if ((min !== null && length < min) || (max !== null && length > max)) {
|
||||
errors.default = t('The default value is outside the limits.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function numberLimits(field: BuilderField, errors: FieldErrors) {
|
||||
const min = parseNumber(field.min)
|
||||
const max = parseNumber(field.max)
|
||||
if (Number.isNaN(min)) {
|
||||
errors.min = t('Enter a number.')
|
||||
}
|
||||
if (Number.isNaN(max)) {
|
||||
errors.max = t('Enter a number.')
|
||||
}
|
||||
if (min !== null && max !== null && min > max) {
|
||||
errors.min = t("Lowest value can't be above the highest value.")
|
||||
}
|
||||
if (field.defaultValue !== '' && !field.unique) {
|
||||
const value = parseNumber(field.defaultValue)
|
||||
if (value === null || Number.isNaN(value)) {
|
||||
errors.default = t('Enter a number.')
|
||||
} else if (
|
||||
(min !== null && !Number.isNaN(min) && value < min) ||
|
||||
(max !== null && !Number.isNaN(max) && value > max)
|
||||
) {
|
||||
errors.default = t('The default value is outside the limits.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dateLimits(field: BuilderField, errors: FieldErrors) {
|
||||
const min = field.min
|
||||
const max = field.max
|
||||
if (min !== '' && max !== '' && min > max) {
|
||||
errors.min = t("Earliest date can't be after the latest date.")
|
||||
}
|
||||
if (field.defaultValue !== '' && !field.unique) {
|
||||
if ((min !== '' && field.defaultValue < min) || (max !== '' && field.defaultValue > max)) {
|
||||
errors.default = t('The default value is outside the limits.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function choiceErrors(field: BuilderField, errors: FieldErrors) {
|
||||
const options = trimmedOptions(field)
|
||||
if (options.length === 0) {
|
||||
errors.options = t('Add at least one choice.')
|
||||
return
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const rows: Record<number, string> = {}
|
||||
options.forEach((option, index) => {
|
||||
if (option === '') {
|
||||
rows[index] = t("Choices can't be blank.")
|
||||
} else if (seen.has(option)) {
|
||||
rows[index] = t('Two choices are the same.')
|
||||
} else {
|
||||
seen.add(option)
|
||||
}
|
||||
})
|
||||
if (Object.keys(rows).length > 0) {
|
||||
errors.choices = rows
|
||||
}
|
||||
}
|
||||
|
||||
function keyError(field: BuilderField, earlierKeys: readonly string[]) {
|
||||
if (field.keyMode === 'auto' && field.label.trim() === '') {
|
||||
return undefined
|
||||
}
|
||||
if (!isValidFieldKey(field.key)) {
|
||||
return t('Start with a letter. Use lowercase letters, digits and underscores.')
|
||||
}
|
||||
if (earlierKeys.includes(field.key)) {
|
||||
return t('Another field already uses this key.')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function validateField(field: BuilderField, earlierKeys: readonly string[]): FieldErrors {
|
||||
const errors: FieldErrors = {}
|
||||
if (field.label.trim() === '') {
|
||||
errors.label = t('Give the field a label.')
|
||||
}
|
||||
const key = keyError(field, earlierKeys)
|
||||
if (key) {
|
||||
errors.key = key
|
||||
}
|
||||
if (field.type === 'text' || field.type === 'richtext') {
|
||||
textLimits(field, errors)
|
||||
} else if (field.type === 'number') {
|
||||
numberLimits(field, errors)
|
||||
} else if (field.type === 'date') {
|
||||
dateLimits(field, errors)
|
||||
} else if (field.type === 'select') {
|
||||
choiceErrors(field, errors)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
export function hasFieldErrors(errors: FieldErrors) {
|
||||
return Object.keys(errors).length > 0
|
||||
}
|
||||
|
||||
export function validateForm(form: BuilderForm): FormErrors {
|
||||
const fields = new Map<number, FieldErrors>()
|
||||
const earlierKeys: string[] = []
|
||||
for (const field of form.fields) {
|
||||
const errors = validateField(field, earlierKeys)
|
||||
if (hasFieldErrors(errors)) {
|
||||
fields.set(field.uid, errors)
|
||||
}
|
||||
earlierKeys.push(field.key)
|
||||
}
|
||||
return { type: validateType(form), fields }
|
||||
}
|
||||
|
||||
export function countErrors(errors: FormErrors) {
|
||||
let total = Object.keys(errors.type).length
|
||||
for (const fieldErrors of errors.fields.values()) {
|
||||
total += Object.keys(fieldErrors).length
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
export function countProblems(errors: FormErrors) {
|
||||
return Object.keys(errors.type).length + errors.fields.size
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const BOTTOM_SHEET_UI = {
|
||||
content:
|
||||
'h-11/12 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
||||
header: 'h-14 min-h-14 border-b border-default px-4 py-0 sm:px-4',
|
||||
title: 'text-lg font-semibold text-highlighted',
|
||||
description: 'sr-only',
|
||||
close: 'top-4.5 end-2 size-11 justify-center p-0',
|
||||
body: 'flex flex-col gap-4 p-4 sm:p-4',
|
||||
footer: 'border-t border-default px-4 py-3 sm:px-4',
|
||||
}
|
||||
@@ -189,6 +189,44 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('The new content type route is registered before the type key route, verified-only, titled Schema', () => {
|
||||
const resolved = router.resolve('/schema/new')
|
||||
|
||||
expect(resolved.matched).toHaveLength(1)
|
||||
expect(resolved.matched[0]!.path).toBe('/schema/new')
|
||||
expect(resolved.params.key).toBeUndefined()
|
||||
expect(resolved.meta.auth).toBe('verified')
|
||||
expect(resolved.meta.title).toBe('Schema')
|
||||
expect(resolved.meta.needsBuiltinTypes).not.toBe(true)
|
||||
expect(router.resolve('/schema/articles').meta.needsBuiltinTypes).toBe(true)
|
||||
expect(router.resolve('/schema').meta.needsBuiltinTypes).toBe(true)
|
||||
})
|
||||
|
||||
it('The new content type route redirects an unverified session to the pending page', async () => {
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: false })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||
setUpSession()
|
||||
|
||||
await router.push('/schema/new')
|
||||
mountApp()
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
|
||||
})
|
||||
|
||||
it('The new content type route redirects a signed-out visitor to login with the original path', async () => {
|
||||
const getMe = vi.fn().mockRejectedValue(new ApiError(401))
|
||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||
setUpSession()
|
||||
|
||||
await router.push('/schema/new')
|
||||
mountApp()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
'/login?redirect=/schema/new&reason=expired',
|
||||
)
|
||||
})
|
||||
|
||||
it('A session-only route admits an unverified session', async () => {
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: false })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||
|
||||
@@ -90,12 +90,17 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/schema',
|
||||
component: () => import('@/views/SchemaView.vue'),
|
||||
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
|
||||
},
|
||||
{
|
||||
path: '/schema/new',
|
||||
component: () => import('@/views/SchemaBuilderView.vue'),
|
||||
meta: { auth: 'verified', title: 'Schema' },
|
||||
},
|
||||
{
|
||||
path: '/schema/:key',
|
||||
component: () => import('@/views/SchemaTypeView.vue'),
|
||||
meta: { auth: 'verified', title: 'Schema' },
|
||||
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
|
||||
},
|
||||
{
|
||||
path: '/activity',
|
||||
|
||||
@@ -194,4 +194,89 @@ describe('content types store', () => {
|
||||
expect(store.status).toBe('idle')
|
||||
expect(store.builtinStatus).toBe('idle')
|
||||
})
|
||||
|
||||
describe('add', () => {
|
||||
const recipes = { ...events, id: 2, key: 'recipes', name: 'Recipes' }
|
||||
const agenda = { ...events, id: 3, key: 'agenda', name: 'agenda' }
|
||||
|
||||
it('inserts the type in name order, ignoring case, once the list is ready', async () => {
|
||||
listContentTypes.mockResolvedValue([events, recipes])
|
||||
const store = useContentTypesStore()
|
||||
await store.load()
|
||||
|
||||
store.add(agenda)
|
||||
store.add({ ...events, id: 4, key: 'press', name: 'Press mentions' })
|
||||
store.add({ ...events, id: 5, key: 'zoo', name: 'Zoo' })
|
||||
|
||||
expect(store.items.map((item) => item.name)).toEqual([
|
||||
'agenda',
|
||||
'Events',
|
||||
'Press mentions',
|
||||
'Recipes',
|
||||
'Zoo',
|
||||
])
|
||||
})
|
||||
|
||||
it('does nothing while the list is not loaded, so the next load returns it', async () => {
|
||||
listContentTypes.mockResolvedValue([events, agenda])
|
||||
const store = useContentTypesStore()
|
||||
|
||||
store.add(agenda)
|
||||
expect(store.items).toEqual([])
|
||||
expect(store.status).toBe('idle')
|
||||
|
||||
await store.load()
|
||||
expect(store.items).toEqual([events, agenda])
|
||||
})
|
||||
|
||||
it('leaves the built-in items alone and sends no request', async () => {
|
||||
listContentTypes.mockResolvedValue([events])
|
||||
listBuiltinTypes.mockResolvedValue([{ key: 'articles', name: 'Articles', fields: [] }])
|
||||
const store = useContentTypesStore()
|
||||
await store.load({ builtin: true })
|
||||
listContentTypes.mockClear()
|
||||
listBuiltinTypes.mockClear()
|
||||
|
||||
store.add(recipes)
|
||||
|
||||
expect(store.builtinItems).toHaveLength(1)
|
||||
expect(listContentTypes).not.toHaveBeenCalled()
|
||||
expect(listBuiltinTypes).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a type created while the custom list is still loading', async () => {
|
||||
const first = deferred()
|
||||
listContentTypes.mockReturnValueOnce(first.promise).mockResolvedValueOnce([events])
|
||||
const store = useContentTypesStore()
|
||||
|
||||
const loaded = store.load()
|
||||
store.add(events)
|
||||
first.resolve([])
|
||||
await loaded
|
||||
|
||||
expect(listContentTypes).toHaveBeenCalledTimes(2)
|
||||
expect(store.items).toEqual([events])
|
||||
expect(store.customLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refetch when nothing was created during the load', async () => {
|
||||
listContentTypes.mockResolvedValue([events])
|
||||
const store = useContentTypesStore()
|
||||
|
||||
await store.load()
|
||||
|
||||
expect(listContentTypes).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores an add before any load started', async () => {
|
||||
listContentTypes.mockResolvedValue([])
|
||||
const store = useContentTypesStore()
|
||||
|
||||
store.add(events)
|
||||
await store.load()
|
||||
|
||||
expect(listContentTypes).toHaveBeenCalledTimes(1)
|
||||
expect(store.items).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -14,13 +14,18 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
|
||||
const status = ref<ContentTypesStatus>('idle')
|
||||
let pending: Promise<void> | null = null
|
||||
let generation = 0
|
||||
let stale = false
|
||||
|
||||
async function run(token: number) {
|
||||
try {
|
||||
const listed = await fetcher()
|
||||
let listed: T[]
|
||||
do {
|
||||
stale = false
|
||||
listed = await fetcher()
|
||||
if (token !== generation) {
|
||||
return
|
||||
}
|
||||
} while (stale)
|
||||
items.value = listed
|
||||
status.value = 'ready'
|
||||
} catch (error) {
|
||||
@@ -52,6 +57,7 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
|
||||
|
||||
function retry() {
|
||||
generation++
|
||||
stale = false
|
||||
pending = null
|
||||
status.value = 'idle'
|
||||
return load()
|
||||
@@ -59,14 +65,28 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
|
||||
|
||||
function reset() {
|
||||
generation++
|
||||
stale = false
|
||||
pending = null
|
||||
items.value = []
|
||||
status.value = 'idle'
|
||||
}
|
||||
|
||||
function insert(item: T, before: (existing: T) => boolean) {
|
||||
if (status.value === 'loading') {
|
||||
stale = true
|
||||
return
|
||||
}
|
||||
if (status.value !== 'ready') {
|
||||
return
|
||||
}
|
||||
const list = items.value as T[]
|
||||
const index = list.findIndex(before)
|
||||
list.splice(index === -1 ? list.length : index, 0, item)
|
||||
}
|
||||
|
||||
const loading = computed(() => status.value === 'idle' || status.value === 'loading')
|
||||
|
||||
return { items, status, loading, load, retry, reset }
|
||||
return { items, status, loading, load, retry, reset, insert }
|
||||
}
|
||||
|
||||
export const useContentTypesStore = defineStore('contentTypes', () => {
|
||||
@@ -93,6 +113,11 @@ export const useContentTypesStore = defineStore('contentTypes', () => {
|
||||
return Promise.all(requests).then(() => undefined)
|
||||
}
|
||||
|
||||
function add(type: ContentType) {
|
||||
const name = type.name.toLowerCase()
|
||||
custom.insert(type, (existing) => existing.name.toLowerCase() > name)
|
||||
}
|
||||
|
||||
function retryCustom() {
|
||||
return custom.retry()
|
||||
}
|
||||
@@ -116,6 +141,7 @@ export const useContentTypesStore = defineStore('contentTypes', () => {
|
||||
error,
|
||||
forbidden,
|
||||
load,
|
||||
add,
|
||||
retryCustom,
|
||||
retryBuiltin,
|
||||
reset,
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import ApiPreview from '@/components/ApiPreview.vue'
|
||||
import ApiPreviewSheet from '@/components/ApiPreviewSheet.vue'
|
||||
import BuilderActions from '@/components/BuilderActions.vue'
|
||||
import BuilderBadge from '@/components/BuilderBadge.vue'
|
||||
import BuilderInspector from '@/components/BuilderInspector.vue'
|
||||
import BuilderTypeBar from '@/components/BuilderTypeBar.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import FieldList from '@/components/FieldList.vue'
|
||||
import FieldSheet from '@/components/FieldSheet.vue'
|
||||
import RetryAlert from '@/components/RetryAlert.vue'
|
||||
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import { useSchemaBuilder } from '@/composables/useSchemaBuilder'
|
||||
import { useSchemaGate } from '@/composables/useSchemaGate'
|
||||
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
||||
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
|
||||
import { sampleJson } from '@/lib/schemaBuilderSample'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const OFFLINE_COPY = "You're offline. Changes will be saved when you reconnect."
|
||||
|
||||
const router = useRouter()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const { sessionReady, forbidden } = useSchemaGate('custom')
|
||||
const builder = useSchemaBuilder()
|
||||
const { form } = builder
|
||||
|
||||
useUnsavedChangesGuard({
|
||||
isDirty: () => builder.dirty.value,
|
||||
title: () => form.value.name.trim() || t('New content type'),
|
||||
})
|
||||
|
||||
const apiOpen = ref(false)
|
||||
const apiSheetOpen = ref(false)
|
||||
const fieldSheetOpen = ref(false)
|
||||
|
||||
const showForbidden = computed(() => forbidden.value || builder.failure.value === 'forbidden')
|
||||
const mobileTitle = computed(() => t('New content type'))
|
||||
const breadcrumb = computed(() => [
|
||||
{ label: t('Schema'), to: SCHEMA_PATH },
|
||||
{ label: t('New content type') },
|
||||
])
|
||||
const json = computed(() => sampleJson(form.value.key, form.value.fields))
|
||||
const selectedIndex = computed(() =>
|
||||
form.value.fields.findIndex((field) => field.uid === builder.selectedUid.value),
|
||||
)
|
||||
const selectedErrors = computed(() =>
|
||||
builder.selected.value
|
||||
? (builder.shown.value.fields.get(builder.selected.value.uid) ?? {})
|
||||
: {},
|
||||
)
|
||||
const selectedName = computed(() => builder.selected.value?.label.trim() || t('Untitled field'))
|
||||
const sheetTitle = computed(() => t('Field: {0}', [selectedName.value]))
|
||||
|
||||
function goHome() {
|
||||
router.push(HOME_PATH)
|
||||
}
|
||||
|
||||
function onSelect(uid: number) {
|
||||
builder.selectField(uid)
|
||||
if (!isDesktop.value) {
|
||||
fieldSheetOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onAdd() {
|
||||
builder.addField()
|
||||
if (!isDesktop.value) {
|
||||
fieldSheetOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onRemoveSelected() {
|
||||
const uid = builder.selectedUid.value
|
||||
if (uid !== null) {
|
||||
builder.removeField(uid)
|
||||
}
|
||||
if (!isDesktop.value) {
|
||||
fieldSheetOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onMoveSelected(delta: number) {
|
||||
const uid = builder.selectedUid.value
|
||||
if (uid !== null) {
|
||||
builder.moveField(uid, delta)
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreRowFocus() {
|
||||
await nextTick()
|
||||
const uid = builder.selectedUid.value
|
||||
document.querySelector<HTMLElement>(`[data-field-row][data-uid="${uid}"]`)?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell
|
||||
full-bleed
|
||||
:mobile-title="mobileTitle"
|
||||
:back-to="SCHEMA_PATH"
|
||||
:back-label="t('Schema')"
|
||||
:offline-message="t(OFFLINE_COPY)"
|
||||
offline-icon="i-lucide-wifi-off"
|
||||
>
|
||||
<template #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="breadcrumb"
|
||||
color="neutral"
|
||||
class="hidden lg:block"
|
||||
:ui="{ link: 'text-sm font-medium' }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<SchemaForbidden v-if="showForbidden" @back="goHome" />
|
||||
|
||||
<div
|
||||
v-else-if="sessionReady"
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
data-testid="schema-builder"
|
||||
>
|
||||
<div class="shrink-0 border-b border-default bg-default p-4 md:px-6">
|
||||
<BuilderTypeBar
|
||||
:name="form.name"
|
||||
:key-value="form.key"
|
||||
:key-mode="form.keyMode"
|
||||
:name-error="builder.shown.value.type.name"
|
||||
:key-error="builder.shown.value.type.key"
|
||||
:disabled="builder.saving.value"
|
||||
:key-focus-token="builder.keyFocusToken.value"
|
||||
@update:name="builder.setName"
|
||||
@update:key="builder.setKey"
|
||||
@unlock="builder.unlockKey"
|
||||
@reset="builder.resetKey"
|
||||
>
|
||||
<template v-if="isDesktop" #actions>
|
||||
<BuilderActions
|
||||
:status="builder.status.value"
|
||||
:problems="builder.problems.value"
|
||||
:can-save="builder.canSave.value"
|
||||
:can-reveal="builder.canReveal.value"
|
||||
:can-discard="builder.canDiscard.value"
|
||||
:saving="builder.saving.value"
|
||||
@save="builder.save"
|
||||
@discard="builder.discard"
|
||||
/>
|
||||
</template>
|
||||
</BuilderTypeBar>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="builder.failure.value === 'failed' || builder.failure.value === 'invalid'"
|
||||
class="px-4 pt-3 md:px-6"
|
||||
>
|
||||
<RetryAlert
|
||||
v-if="builder.failure.value === 'failed'"
|
||||
:title="t('Couldn\'t save the content type')"
|
||||
:description="
|
||||
t(
|
||||
'Something went wrong on our side. Your changes are still here. Try again in a moment.',
|
||||
)
|
||||
"
|
||||
@retry="builder.save"
|
||||
/>
|
||||
<UAlert
|
||||
v-else
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="t('Check the fields marked in the list.')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<div
|
||||
class="flex flex-col gap-2 p-4 md:w-95 md:shrink-0 md:border-r md:border-default md:bg-muted"
|
||||
>
|
||||
<FieldList
|
||||
:fields="form.fields"
|
||||
:selected-uid="builder.selectedUid.value"
|
||||
:errors="builder.shown.value.fields"
|
||||
:list-error="builder.shown.value.type.fields"
|
||||
:disabled="builder.saving.value"
|
||||
:mobile="!isDesktop"
|
||||
@select="onSelect"
|
||||
@add="onAdd"
|
||||
@remove="builder.removeField"
|
||||
@move="builder.moveField"
|
||||
@reorder="builder.reorderFields"
|
||||
>
|
||||
<button
|
||||
v-if="!isDesktop"
|
||||
type="button"
|
||||
data-testid="api-row"
|
||||
class="flex min-h-11 items-center gap-2.5 border border-default bg-default px-3 text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
|
||||
@click="apiSheetOpen = true"
|
||||
>
|
||||
<UIcon name="i-lucide-braces" class="size-4" />
|
||||
<span class="flex-1 text-left font-medium">{{ t('API preview') }}</span>
|
||||
<UIcon name="i-lucide-chevron-right" class="size-3.5 text-muted" />
|
||||
</button>
|
||||
</FieldList>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isDesktop"
|
||||
class="flex min-w-0 flex-1 flex-col gap-3.5 bg-default px-6 py-4"
|
||||
data-testid="inspector-pane"
|
||||
>
|
||||
<template v-if="builder.selected.value">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-md font-semibold text-highlighted">
|
||||
{{ sheetTitle }}
|
||||
</h2>
|
||||
<BuilderBadge kind="new" />
|
||||
</div>
|
||||
<BuilderInspector
|
||||
:builder="builder"
|
||||
:field="builder.selected.value"
|
||||
:errors="selectedErrors"
|
||||
:compact="false"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h2 class="text-md font-semibold text-highlighted">
|
||||
{{ t('Field settings') }}
|
||||
</h2>
|
||||
<p class="text-muted">
|
||||
{{ t('Add a field, then choose its type and rules here.') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ApiPreview
|
||||
v-if="isDesktop"
|
||||
v-model:open="apiOpen"
|
||||
:key-value="form.key"
|
||||
:json="json"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="sticky bottom-0 mt-auto border-t border-default bg-default px-4 py-3"
|
||||
>
|
||||
<BuilderActions
|
||||
:status="builder.status.value"
|
||||
:problems="builder.problems.value"
|
||||
:can-save="builder.canSave.value"
|
||||
:can-reveal="builder.canReveal.value"
|
||||
:can-discard="builder.canDiscard.value"
|
||||
:saving="builder.saving.value"
|
||||
@save="builder.save"
|
||||
@discard="builder.discard"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!isDesktop && sessionReady && !showForbidden">
|
||||
<ApiPreviewSheet v-model:open="apiSheetOpen" :key-value="form.key" :json="json" />
|
||||
<FieldSheet
|
||||
v-model:open="fieldSheetOpen"
|
||||
:title="sheetTitle"
|
||||
:first="selectedIndex <= 0"
|
||||
:last="selectedIndex === form.fields.length - 1"
|
||||
:disabled="builder.saving.value"
|
||||
@up="onMoveSelected(-1)"
|
||||
@down="onMoveSelected(1)"
|
||||
@remove="onRemoveSelected"
|
||||
@closed="restoreRowFocus"
|
||||
>
|
||||
<BuilderInspector
|
||||
v-if="builder.selected.value"
|
||||
:builder="builder"
|
||||
:field="builder.selected.value"
|
||||
:errors="selectedErrors"
|
||||
compact
|
||||
/>
|
||||
</FieldSheet>
|
||||
</template>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -11,8 +11,7 @@ import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import SchemaTable from '@/components/SchemaTable.vue'
|
||||
import SchemaTypeNotFound from '@/components/SchemaTypeNotFound.vue'
|
||||
import { useSchemaGate } from '@/composables/useSchemaGate'
|
||||
|
||||
const LIST_PATH = '/schema'
|
||||
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -23,12 +22,12 @@ const failed = computed(() => !forbidden.value && builtinFailed.value)
|
||||
const type = computed(() => builtinItems.value.find((item) => item.key === route.params.key))
|
||||
const mobileTitle = computed(() => type.value?.name ?? t('Schema'))
|
||||
const breadcrumb = computed(() => [
|
||||
{ label: t('Schema'), to: LIST_PATH },
|
||||
{ label: t('Schema'), to: SCHEMA_PATH },
|
||||
...(type.value ? [{ label: type.value.name }] : []),
|
||||
])
|
||||
|
||||
function goHome() {
|
||||
router.push('/home')
|
||||
router.push(HOME_PATH)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -37,7 +36,7 @@ function goHome() {
|
||||
compact-padding
|
||||
:full-bleed="forbidden || (sessionReady && !loading && !failed && !type)"
|
||||
:mobile-title="mobileTitle"
|
||||
:back-to="LIST_PATH"
|
||||
:back-to="SCHEMA_PATH"
|
||||
:back-label="t('Schema')"
|
||||
>
|
||||
<template #breadcrumb>
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { button, mountAt, posts, text, unmountAll } from './schemaTestKit'
|
||||
import {
|
||||
all,
|
||||
byLabel,
|
||||
chip,
|
||||
click,
|
||||
discardButton,
|
||||
el,
|
||||
keyField,
|
||||
keyInput,
|
||||
main,
|
||||
nameInput,
|
||||
rows,
|
||||
saveButton,
|
||||
typeInto,
|
||||
} from './builderKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
const KEY_MESSAGE = 'Use 2 to 40 lowercase letters, digits or hyphens.'
|
||||
|
||||
function errorsOf(container: Element | null) {
|
||||
return [...(container?.querySelectorAll('[role="alert"]') ?? [])].map(text)
|
||||
}
|
||||
|
||||
function nameField() {
|
||||
return nameInput()!.closest<HTMLElement>('[data-orientation]')!
|
||||
}
|
||||
|
||||
async function unlockKey() {
|
||||
await click(keyField().querySelector('button[aria-label="Edit key"]'))
|
||||
}
|
||||
|
||||
describe('Type-level rules (design states 6 and 14)', () => {
|
||||
it.each([
|
||||
['', 'Give the content type a name.'],
|
||||
[' ', 'Give the content type a name.'],
|
||||
])(
|
||||
'shows the name message for %j with an alert icon and sends nothing',
|
||||
async (value, message) => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await typeInto(nameInput(), value)
|
||||
await click(saveButton())
|
||||
|
||||
expect(errorsOf(nameField())).toEqual([message])
|
||||
expect(nameField().querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
expect(posts).toHaveLength(0)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
['e', KEY_MESSAGE],
|
||||
['Events', KEY_MESSAGE],
|
||||
['my_type', KEY_MESSAGE],
|
||||
['-events', KEY_MESSAGE],
|
||||
['events--2', KEY_MESSAGE],
|
||||
['new', 'The key "new" is reserved. Choose a different key.'],
|
||||
])('shows the key message for %j and sends nothing', async (value, message) => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await unlockKey()
|
||||
|
||||
await typeInto(keyInput(), value)
|
||||
await click(saveButton())
|
||||
|
||||
expect(errorsOf(keyField())).toEqual([message])
|
||||
expect(keyField().querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
expect(posts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each(['ab', 'press-mentions', '2026-awards', 'a'.repeat(40)])(
|
||||
'sends the accepted key %j',
|
||||
async (key) => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'X')
|
||||
await unlockKey()
|
||||
await typeInto(keyInput(), key)
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.body.key).toBe(key)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Type-level errors together (design state 6)', () => {
|
||||
async function reachState6() {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'x')
|
||||
await unlockKey()
|
||||
await typeInto(keyInput(), 'e')
|
||||
await typeInto(nameInput(), '')
|
||||
await click(byLabel('Remove'))
|
||||
}
|
||||
|
||||
it('shows the three messages, the empty list, the chip, and disables Save only', async () => {
|
||||
await reachState6()
|
||||
|
||||
expect(errorsOf(nameField())).toEqual(['Give the content type a name.'])
|
||||
expect(errorsOf(keyField())).toEqual([KEY_MESSAGE])
|
||||
const empty = el('[data-testid="field-empty"]')!
|
||||
expect(text(empty)).toContain('No fields yet')
|
||||
expect(text(empty)).toContain('A content type needs at least one field.')
|
||||
expect(empty.querySelector('.text-error')).not.toBeNull()
|
||||
expect(chip()).toBe('3 problems to fix')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(discardButton()!.disabled).toBe(false)
|
||||
expect(text(el('[data-testid="inspector-pane"]'))).toContain('Field settings')
|
||||
expect(text(el('[data-testid="inspector-pane"]'))).toContain(
|
||||
'Add a field, then choose its type and rules here.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reveals every error at once when Save is activated, then enables Save once fixed', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await click(byLabel('Remove'))
|
||||
await click(button('Add field'))
|
||||
expect(chip()).toBe('3 problems to fix')
|
||||
expect(errorsOf(nameField())).toEqual([])
|
||||
expect(errorsOf(keyField())).toEqual([])
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
expect(saveButton()!.getAttribute('aria-disabled')).toBe('true')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(chip()).toBe('3 problems to fix')
|
||||
expect(errorsOf(nameField())).toEqual(['Give the content type a name.'])
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(posts).toHaveLength(0)
|
||||
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await typeInto(el<HTMLInputElement>('input[name="label"]'), 'Title')
|
||||
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('When errors are shown', () => {
|
||||
it('shows none on a pristine form', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(all('[role="alert"]')).toHaveLength(0)
|
||||
expect(all('[aria-invalid="true"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows an error for the input being edited and for no other', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await click(button('Add field'))
|
||||
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await typeInto(nameInput(), '')
|
||||
|
||||
expect(errorsOf(main())).toEqual(['Give the content type a name.'])
|
||||
})
|
||||
|
||||
it('ties each error to its input with aria-invalid and aria-describedby', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'x')
|
||||
await typeInto(nameInput(), '')
|
||||
|
||||
const input = nameInput()!
|
||||
expect(input.getAttribute('aria-invalid')).toBe('true')
|
||||
const describedBy = input.getAttribute('aria-describedby')!
|
||||
expect(describedBy).not.toBe('')
|
||||
expect(text(document.getElementById(describedBy.split(' ')[0]!))).toBe(
|
||||
'Give the content type a name.',
|
||||
)
|
||||
})
|
||||
|
||||
it('marks a row with an error with an alert icon, not only a border', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await click(button('Add field'))
|
||||
await typeInto(el<HTMLInputElement>('input[name="label"]'), 'x')
|
||||
await typeInto(el<HTMLInputElement>('input[name="label"]'), '')
|
||||
|
||||
const invalid = rows().filter((row) => row.querySelector('[data-testid="field-row-alert"]'))
|
||||
expect(invalid).toHaveLength(1)
|
||||
expect(invalid[0]!.className).toContain('border-error')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,727 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { button, failWithBody, mountAt, posts, text, unmountAll } from './schemaTestKit'
|
||||
import {
|
||||
addChoice,
|
||||
addChoiceInput,
|
||||
addField,
|
||||
all,
|
||||
byLabel,
|
||||
choiceValues,
|
||||
chip,
|
||||
click,
|
||||
defaultInput,
|
||||
el,
|
||||
fieldKeyInput,
|
||||
inspector,
|
||||
labelInput,
|
||||
limit,
|
||||
nameInput,
|
||||
pickTile,
|
||||
press,
|
||||
rowLabels,
|
||||
rows,
|
||||
saveButton,
|
||||
selectRow,
|
||||
switchOf,
|
||||
tile,
|
||||
toggle,
|
||||
typeInto,
|
||||
} from './builderKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = () => undefined
|
||||
})
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
function fieldRoot(input: HTMLElement | null) {
|
||||
return input!.closest<HTMLElement>('[data-orientation]')!
|
||||
}
|
||||
|
||||
function errorsIn(container: Element | null) {
|
||||
return [...(container?.querySelectorAll('[role="alert"]') ?? [])].map(text)
|
||||
}
|
||||
|
||||
async function unlockFieldKey() {
|
||||
await click(fieldRoot(fieldKeyInput()).querySelector('button[aria-label="Edit key"]'))
|
||||
}
|
||||
|
||||
const KEY_RULE = 'Start with a letter. Use lowercase letters, digits and underscores.'
|
||||
|
||||
describe('Field list rows', () => {
|
||||
it('shows a grip, the type icon box, the label and the mono key of each row', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Starts on')
|
||||
await pickTile('Date')
|
||||
await addField('Online')
|
||||
await pickTile('Yes or no')
|
||||
|
||||
expect(rowLabels()).toEqual(['Title', 'Starts on', 'Online'])
|
||||
const second = rows()[1]!
|
||||
expect(second.querySelector('[data-grip]')!.getAttribute('aria-label')).toBe(
|
||||
'Move Starts on',
|
||||
)
|
||||
expect(second.querySelector('[data-field-row] svg')).not.toBeNull()
|
||||
expect(text(second.querySelector('[data-testid="field-row-key"]'))).toBe('starts_on')
|
||||
})
|
||||
|
||||
it('shows the badges on an unselected row and the actions on the selected one only', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await toggle('Required')
|
||||
await toggle('Unique values')
|
||||
await addField('Second')
|
||||
|
||||
const first = rows()[0]!
|
||||
expect(text(first.querySelector('[data-testid="field-row-badges"]'))).toBe(
|
||||
'New Required Unique',
|
||||
)
|
||||
expect(first.querySelector('[data-action]')).toBeNull()
|
||||
const selected = rows()[1]!
|
||||
expect(selected.className).toContain('bg-(--rc-sand)')
|
||||
expect(
|
||||
[...selected.querySelectorAll('[data-action]')].map((node) =>
|
||||
node.getAttribute('aria-label'),
|
||||
),
|
||||
).toEqual(['Move up', 'Move down', 'Remove'])
|
||||
})
|
||||
|
||||
it('reads Untitled field and a dash while a field has no label or key', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await click(button('Add field'))
|
||||
|
||||
const added = rows()[1]!
|
||||
expect(text(added.querySelector('[data-testid="field-row-label"]'))).toBe('Untitled field')
|
||||
expect(text(added.querySelector('[data-testid="field-row-key"]'))).toBe('–')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Adding a field', () => {
|
||||
it('appends a selected Text row, focuses the label and counts 2 of 50', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await click(button('Add field'))
|
||||
|
||||
expect(rows()).toHaveLength(2)
|
||||
expect(rows()[1]!.querySelector('[data-field-row]')!.getAttribute('aria-current')).toBe(
|
||||
'true',
|
||||
)
|
||||
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
|
||||
'Text',
|
||||
)
|
||||
expect(labelInput()!.value).toBe('')
|
||||
expect(document.activeElement).toBe(labelInput())
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('2 of 50')
|
||||
})
|
||||
|
||||
it('disables Add field at 50 fields', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
for (let count = 1; count < 50; count++) {
|
||||
await click(button('Add field'))
|
||||
}
|
||||
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('50 of 50')
|
||||
expect((button('Add field') as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Field key from the label', () => {
|
||||
it.each([
|
||||
['Title', 'title_2'],
|
||||
['Starts on', 'starts_on'],
|
||||
['Éléments', 'elements'],
|
||||
['Ends on (date)', 'ends_on_date'],
|
||||
['2nd room', 'nd_room'],
|
||||
['a'.repeat(60), 'a'.repeat(40)],
|
||||
])('label %j gives the key %j with the Auto badge', async (label, key) => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await addField(label)
|
||||
|
||||
expect(fieldKeyInput()!.value).toBe(key)
|
||||
expect(fieldKeyInput()!.readOnly).toBe(true)
|
||||
expect(text(fieldRoot(fieldKeyInput()))).toContain('Auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Field key rules (design state 7)', () => {
|
||||
it.each(['Title', '1st', 'a-b', 'a b', ''])('shows the key message for %j', async (key) => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Thing')
|
||||
await unlockFieldKey()
|
||||
|
||||
await typeInto(fieldKeyInput(), key)
|
||||
|
||||
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([KEY_RULE])
|
||||
})
|
||||
|
||||
it.each(['starts_on', 'a', 'a'.repeat(40)])('accepts %j', async (key) => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Thing')
|
||||
await unlockFieldKey()
|
||||
|
||||
await typeInto(fieldKeyInput(), key)
|
||||
|
||||
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([])
|
||||
expect(fieldKeyInput()!.maxLength).toBe(40)
|
||||
})
|
||||
|
||||
it('flags a key already used by an earlier field under the second field', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await addField('Second')
|
||||
await unlockFieldKey()
|
||||
|
||||
await typeInto(fieldKeyInput(), 'title')
|
||||
|
||||
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([
|
||||
'Another field already uses this key.',
|
||||
])
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
})
|
||||
|
||||
it('shows one error with the alert icon, the chip and a disabled Save for a hyphenated key', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await addField('Start date')
|
||||
await unlockFieldKey()
|
||||
|
||||
await typeInto(fieldKeyInput(), 'start-date')
|
||||
|
||||
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([KEY_RULE])
|
||||
expect(fieldRoot(fieldKeyInput()).querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
const TILES = [
|
||||
['Text', 'Short text, like a title or a name', 'type'],
|
||||
['Rich text', 'Long text with formatting, written in Markdown', 'pilcrow'],
|
||||
['Number', 'Whole or decimal numbers', 'hash'],
|
||||
['Yes or no', 'An on or off switch', 'toggle-left'],
|
||||
['Date', 'A calendar day, without a time', 'calendar'],
|
||||
['Choice', 'One option from a list you define', 'list'],
|
||||
] as const
|
||||
|
||||
describe('Type tiles', () => {
|
||||
it('shows six radio tiles with an icon, a name and a description', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(el('[role="radiogroup"]')).not.toBeNull()
|
||||
for (const [name, description, icon] of TILES) {
|
||||
const radio = tile(name)
|
||||
const label = radio.closest('label')!
|
||||
expect(text(label)).toBe(`${name} ${description}`)
|
||||
expect(label.querySelector('svg')).not.toBeNull()
|
||||
expect(icon).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('marks the selected tile with the sand background', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await pickTile('Date')
|
||||
|
||||
const label = tile('Date').closest('label')!
|
||||
expect(tile('Date').getAttribute('aria-checked')).toBe('true')
|
||||
expect(label.className).toContain('has-data-[state=checked]:bg-(--rc-sand)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The inspector follows the type', () => {
|
||||
it.each([
|
||||
[
|
||||
'Text',
|
||||
'Shortest',
|
||||
'Longest',
|
||||
true,
|
||||
'characters',
|
||||
'text',
|
||||
'Counted in characters. Entries show a counter.',
|
||||
],
|
||||
[
|
||||
'Rich text',
|
||||
'Shortest',
|
||||
'Longest',
|
||||
false,
|
||||
'characters',
|
||||
'textarea',
|
||||
'Counted in characters. Entries show a counter.',
|
||||
],
|
||||
[
|
||||
'Number',
|
||||
'Lowest value',
|
||||
'Highest value',
|
||||
true,
|
||||
'',
|
||||
'number',
|
||||
'Entries outside the range are refused.',
|
||||
],
|
||||
[
|
||||
'Date',
|
||||
'Earliest date',
|
||||
'Latest date',
|
||||
true,
|
||||
'',
|
||||
'date',
|
||||
'Entries outside the range are refused.',
|
||||
],
|
||||
])('%s', async (name, low, high, unique, suffix, control, help) => {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile(name)
|
||||
|
||||
const copy = text(inspector())
|
||||
expect(copy).toContain(low)
|
||||
expect(copy).toContain(high)
|
||||
expect(copy).toContain(help)
|
||||
expect(copy.includes('characters')).toBe(suffix === 'characters')
|
||||
expect(switchOf('Unique values').disabled).toBe(!unique)
|
||||
expect(copy.includes('Not available for rich text.')).toBe(name === 'Rich text')
|
||||
const field =
|
||||
control === 'textarea'
|
||||
? el<HTMLTextAreaElement>('textarea')
|
||||
: (defaultInput() as HTMLInputElement)
|
||||
expect(field!.placeholder).toBe('No default')
|
||||
if (control !== 'textarea') {
|
||||
expect(defaultInput()!.type).toBe(control)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows no limits and a disabled Unique switch for Yes or no', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile('Yes or no')
|
||||
|
||||
const copy = text(inspector())
|
||||
expect(copy).toContain('Yes or no fields have no limits.')
|
||||
expect(copy).toContain('Not available for yes or no fields.')
|
||||
expect(switchOf('Unique values').disabled).toBe(true)
|
||||
expect(copy).toContain('On by default')
|
||||
expect(copy).toContain('New entries start with this switch off when the field is left out.')
|
||||
expect(limit('min')).toBeNull()
|
||||
expect(defaultInput()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the choice list and no limits for Choice', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile('Choice')
|
||||
|
||||
const copy = text(inspector())
|
||||
expect(copy).toContain('Limits come from the choices below.')
|
||||
expect(copy).toContain('Choices')
|
||||
expect(el('[data-testid="choice-list"]')).not.toBeNull()
|
||||
expect(switchOf('Unique values').disabled).toBe(false)
|
||||
expect(limit('min')).toBeNull()
|
||||
expect(el('button[role="combobox"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Changing the type of a new field', () => {
|
||||
it('keeps the label, key and Required, resets the rest, and starts a Choice with no choice', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Capacity')
|
||||
await pickTile('Number')
|
||||
await toggle('Required')
|
||||
await toggle('Unique values')
|
||||
await typeInto(limit('min'), '1')
|
||||
await typeInto(limit('max'), '5000')
|
||||
await typeInto(defaultInput(), '20')
|
||||
expect(defaultInput()!.disabled).toBe(true)
|
||||
|
||||
await pickTile('Yes or no')
|
||||
|
||||
expect(labelInput()!.value).toBe('Capacity')
|
||||
expect(fieldKeyInput()!.value).toBe('capacity')
|
||||
expect(switchOf('Required').getAttribute('aria-checked')).toBe('true')
|
||||
expect(switchOf('Unique values').getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
await pickTile('Choice')
|
||||
|
||||
expect(choiceValues()).toEqual([])
|
||||
expect(switchOf('Required').getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
await pickTile('Number')
|
||||
|
||||
expect(limit('min')!.value).toBe('')
|
||||
expect(limit('max')!.value).toBe('')
|
||||
expect(defaultInput()!.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Field rules', () => {
|
||||
async function ready(type: string) {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await addField('Thing')
|
||||
await pickTile(type)
|
||||
}
|
||||
|
||||
it('flags an empty label or spaces', async () => {
|
||||
await ready('Text')
|
||||
|
||||
await typeInto(labelInput(), ' ')
|
||||
|
||||
expect(errorsIn(fieldRoot(labelInput()))).toEqual(['Give the field a label.'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Number', { min: '6000', max: '5000' }, "Lowest value can't be above the highest value."],
|
||||
['Text', { min: '10', max: '3' }, "Shortest can't be longer than longest."],
|
||||
[
|
||||
'Date',
|
||||
{ min: '2027-12-31', max: '2026-01-01' },
|
||||
"Earliest date can't be after the latest date.",
|
||||
],
|
||||
['Text', { min: '2.5' }, 'Use a whole number, 0 or more.'],
|
||||
['Text', { min: '-1' }, 'Use a whole number, 0 or more.'],
|
||||
])('flags %s limits %j under the limits', async (type, values, message) => {
|
||||
await ready(type)
|
||||
|
||||
for (const [which, value] of Object.entries(values)) {
|
||||
await typeInto(limit(which as 'min' | 'max'), value)
|
||||
}
|
||||
|
||||
expect(errorsIn(fieldRoot(limit('min')))).toEqual([message])
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Number', { min: '1', max: '5000', default: '0' }],
|
||||
['Text', { max: '3', default: 'abcd' }],
|
||||
])('flags a default outside the limits for %s', async (type, values) => {
|
||||
await ready(type)
|
||||
|
||||
for (const [which, value] of Object.entries(values)) {
|
||||
await typeInto(
|
||||
which === 'default' ? defaultInput() : limit(which as 'min' | 'max'),
|
||||
value,
|
||||
)
|
||||
}
|
||||
|
||||
expect(errorsIn(fieldRoot(defaultInput()))).toEqual([
|
||||
'The default value is outside the limits.',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Number', { min: '1', max: '5000' }],
|
||||
['Number', { min: '5', max: '5' }],
|
||||
['Text', { min: '0' }],
|
||||
])('accepts %s limits %j', async (type, values) => {
|
||||
await ready(type)
|
||||
|
||||
for (const [which, value] of Object.entries(values)) {
|
||||
await typeInto(limit(which as 'min' | 'max'), value)
|
||||
}
|
||||
|
||||
expect(errorsIn(inspector())).toEqual([])
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
})
|
||||
|
||||
it('flags a Choice field with no choice under the choice list', async () => {
|
||||
await ready('Choice')
|
||||
await addChoice('Talk')
|
||||
await click(byLabel('Remove Talk'))
|
||||
|
||||
expect(errorsIn(el('[data-testid="choice-list"]'))).toEqual(['Add at least one choice.'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unique and default', () => {
|
||||
it('disables the default and drops it from the body while Unique is on, then brings it back', async () => {
|
||||
await mountAt('/schema/new', { create: failWithBody(500, 'boom') })
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await typeInto(defaultInput(), 'Draft')
|
||||
|
||||
await toggle('Unique values')
|
||||
|
||||
expect(defaultInput()!.disabled).toBe(true)
|
||||
expect(text(inspector())).toContain("A field with unique values can't have a default.")
|
||||
await click(saveButton())
|
||||
expect(posts[0]!.body.fields).toMatchObject([{ unique: true, default: null }])
|
||||
|
||||
await toggle('Unique values')
|
||||
expect(defaultInput()!.disabled).toBe(false)
|
||||
expect(defaultInput()!.value).toBe('Draft')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Choice list editor (design state 8)', () => {
|
||||
async function withChoices() {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile('Choice')
|
||||
await addChoice('Talk')
|
||||
await addChoice('Workshop')
|
||||
await addChoice('Meetup')
|
||||
}
|
||||
|
||||
it('shows the required marker, the count, the rows, the add input and the help', async () => {
|
||||
await withChoices()
|
||||
|
||||
const list = el('[data-testid="choice-list"]')!
|
||||
expect(text(list)).toContain('Choices *')
|
||||
expect(text(el('[data-testid="choice-count"]'))).toBe('3 of 50')
|
||||
expect(choiceValues()).toEqual(['Talk', 'Workshop', 'Meetup'])
|
||||
expect(all<HTMLInputElement>('[data-testid="choice-row"] input')[0]!.placeholder).toBe(
|
||||
'Choice',
|
||||
)
|
||||
expect(addChoiceInput()!.placeholder).toBe('Add a choice')
|
||||
expect(button('Add')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(text(list)).toContain(
|
||||
'Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save.',
|
||||
)
|
||||
})
|
||||
|
||||
it('appends with the button or Enter, empties the input and keeps the focus', async () => {
|
||||
await withChoices()
|
||||
|
||||
await typeInto(addChoiceInput(), 'Panel')
|
||||
await click(button('Add'))
|
||||
expect(choiceValues()).toEqual(['Talk', 'Workshop', 'Meetup', 'Panel'])
|
||||
expect(addChoiceInput()!.value).toBe('')
|
||||
expect(document.activeElement).toBe(addChoiceInput())
|
||||
|
||||
await typeInto(addChoiceInput(), 'Social')
|
||||
await press(addChoiceInput(), 'Enter')
|
||||
expect(choiceValues().at(-1)).toBe('Social')
|
||||
expect(document.activeElement).toBe(addChoiceInput())
|
||||
})
|
||||
|
||||
it('removes a choice', async () => {
|
||||
await withChoices()
|
||||
|
||||
await click(byLabel('Remove Workshop'))
|
||||
|
||||
expect(choiceValues()).toEqual(['Talk', 'Meetup'])
|
||||
})
|
||||
|
||||
it('lists the choices in the default select and clears the default on remove or rename', async () => {
|
||||
await withChoices()
|
||||
const openSelect = () => press(el('button[role="combobox"]'), 'ArrowDown')
|
||||
await openSelect()
|
||||
const options = all('[role="option"]').map(text)
|
||||
expect(options).toEqual(['None', 'Talk', 'Workshop', 'Meetup'])
|
||||
await press(
|
||||
all('[role="option"]').find((option) => text(option) === 'Workshop'),
|
||||
'Enter',
|
||||
)
|
||||
expect(text(el('button[role="combobox"]'))).toContain('Workshop')
|
||||
|
||||
await typeInto(all<HTMLInputElement>('[data-testid="choice-row"] input')[1], 'Workshops')
|
||||
expect(text(el('button[role="combobox"]'))).toContain('None')
|
||||
|
||||
await openSelect()
|
||||
await press(
|
||||
all('[role="option"]').find((option) => text(option) === 'Meetup'),
|
||||
'Enter',
|
||||
)
|
||||
await click(byLabel('Remove Meetup'))
|
||||
expect(text(el('button[role="combobox"]'))).toContain('None')
|
||||
})
|
||||
|
||||
it('flags a blank row and a duplicate row with an alert icon, as one problem, and caps at 50', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await pickTile('Choice')
|
||||
for (const value of ['Talk', 'Workshop', 'Meetup', 'x', 'y']) {
|
||||
await addChoice(value)
|
||||
}
|
||||
const inputs = () => all<HTMLInputElement>('[data-testid="choice-row"] input')
|
||||
await typeInto(inputs()[3], ' ')
|
||||
await typeInto(inputs()[4], ' Talk ')
|
||||
|
||||
const rowsWithErrors = all('[data-testid="choice-row"]').map((row) => errorsIn(row))
|
||||
expect(rowsWithErrors[3]).toEqual(["Choices can't be blank."])
|
||||
expect(rowsWithErrors[4]).toEqual(['Two choices are the same.'])
|
||||
expect(all('[data-testid="choice-row"] [role="alert"] svg')).toHaveLength(2)
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
expect(inputs()[0]!.getAttribute('aria-invalid')).not.toBe('true')
|
||||
expect(inputs()[4]!.getAttribute('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
it('treats choices as different when they differ by case', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await pickTile('Choice')
|
||||
await addChoice('Talk')
|
||||
await addChoice('talk')
|
||||
|
||||
expect(errorsIn(el('[data-testid="choice-list"]'))).toEqual([])
|
||||
})
|
||||
|
||||
it('disables Add at 50 choices', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile('Choice')
|
||||
for (let index = 0; index < 50; index++) {
|
||||
await addChoice(`C${index}`)
|
||||
}
|
||||
|
||||
expect(text(el('[data-testid="choice-count"]'))).toBe('50 of 50')
|
||||
await typeInto(addChoiceInput(), 'more')
|
||||
expect((button('Add') as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reordering choices', () => {
|
||||
it('moves with the keyboard grip and announces each step', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile('Choice')
|
||||
for (const value of ['Talk', 'Workshop', 'Meetup']) {
|
||||
await addChoice(value)
|
||||
}
|
||||
const grip = () => byLabel('Move Workshop')!
|
||||
|
||||
await press(grip(), ' ')
|
||||
expect(text(el('[data-testid="choice-live"]'))).toBe(
|
||||
'Moving Workshop. Position 2 of 3. Press Up or Down to move, Space to drop, Esc to cancel.',
|
||||
)
|
||||
await press(grip(), 'ArrowUp')
|
||||
expect(text(el('[data-testid="choice-live"]'))).toContain(
|
||||
'Moving Workshop. Position 1 of 3.',
|
||||
)
|
||||
await press(grip(), ' ')
|
||||
|
||||
expect(choiceValues()).toEqual(['Workshop', 'Talk', 'Meetup'])
|
||||
expect(text(el('[data-testid="choice-live"]'))).toBe('Workshop dropped. Position 1 of 3.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Removing a field', () => {
|
||||
it('selects the next row, then the previous at the end, and counts one less', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Starts on')
|
||||
await addField('Ends on')
|
||||
await selectRow('Starts on')
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
expect(rowLabels()).toEqual(['Title', 'Ends on'])
|
||||
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Ends on')
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('2 of 50')
|
||||
expect(text(document.body)).not.toContain('Undo')
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
expect(rowLabels()).toEqual(['Title'])
|
||||
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Title')
|
||||
})
|
||||
|
||||
it('shows the empty state and Field settings when the list is empty', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
|
||||
expect(text(el('[data-testid="field-empty"]'))).toContain('No fields yet')
|
||||
expect(text(el('[data-testid="inspector-pane"]'))).toContain('Field settings')
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('0 of 50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Moving fields with the buttons', () => {
|
||||
async function three() {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Starts on')
|
||||
await addField('Ends on')
|
||||
await selectRow('Starts on')
|
||||
}
|
||||
|
||||
it.each([
|
||||
['Move up', ['Starts on', 'Title', 'Ends on']],
|
||||
['Move down', ['Title', 'Ends on', 'Starts on']],
|
||||
])('%s reorders and keeps the selection', async (label, order) => {
|
||||
await three()
|
||||
|
||||
await click(byLabel(label))
|
||||
|
||||
expect(rowLabels()).toEqual(order)
|
||||
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Starts on')
|
||||
})
|
||||
|
||||
it('keeps the focus on the pressed button while it stays enabled', async () => {
|
||||
await three()
|
||||
await addField('Fourth')
|
||||
await selectRow('Starts on')
|
||||
|
||||
await click(byLabel('Move down'))
|
||||
|
||||
expect(rowLabels()).toEqual(['Title', 'Ends on', 'Starts on', 'Fourth'])
|
||||
expect(document.activeElement).toBe(byLabel('Move down'))
|
||||
})
|
||||
|
||||
it('moves the focus to the other button when the pressed one becomes disabled', async () => {
|
||||
await three()
|
||||
|
||||
await click(byLabel('Move up'))
|
||||
|
||||
expect((byLabel('Move up') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(document.activeElement).toBe(byLabel('Move down'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Title', 'Move up'],
|
||||
['Ends on', 'Move down'],
|
||||
])('disables the end button for %s: %s', async (row, label) => {
|
||||
await three()
|
||||
|
||||
await selectRow(row)
|
||||
|
||||
expect((byLabel(label) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keyboard reorder (design state 9)', () => {
|
||||
async function three() {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Starts on')
|
||||
await addField('Ends on')
|
||||
}
|
||||
const grip = (label: string) => byLabel(`Move ${label}`)!
|
||||
|
||||
it('picks up with Space, moves with Up, drops with Space and announces each step', async () => {
|
||||
await three()
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
expect(text(el('[data-testid="reorder-live"]'))).toBe(
|
||||
'Moving Starts on. Position 2 of 3. Press Up or Down to move, Space to drop, Esc to cancel.',
|
||||
)
|
||||
expect(grip('Starts on').closest('li')!.className).toContain('outline-primary')
|
||||
expect(el('[data-testid="reorder-live"]')!.getAttribute('aria-live')).toBe('polite')
|
||||
|
||||
await press(grip('Starts on'), 'ArrowUp')
|
||||
expect(rowLabels()).toEqual(['Starts on', 'Title', 'Ends on'])
|
||||
expect(text(el('[data-testid="reorder-live"]'))).toContain(
|
||||
'Moving Starts on. Position 1 of 3.',
|
||||
)
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
expect(rowLabels()).toEqual(['Starts on', 'Title', 'Ends on'])
|
||||
expect(text(el('[data-testid="reorder-live"]'))).toBe('Starts on dropped. Position 1 of 3.')
|
||||
})
|
||||
|
||||
it('restores the order on Esc', async () => {
|
||||
await three()
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
await press(grip('Starts on'), 'ArrowUp')
|
||||
await press(grip('Starts on'), 'Escape')
|
||||
|
||||
expect(rowLabels()).toEqual(['Title', 'Starts on', 'Ends on'])
|
||||
expect(text(el('[data-testid="reorder-live"]'))).toContain('Reorder cancelled.')
|
||||
})
|
||||
|
||||
it('has no pointer drag on rows', async () => {
|
||||
await three()
|
||||
|
||||
for (const row of rows()) {
|
||||
expect(row.getAttribute('draggable')).toBeNull()
|
||||
expect(row.querySelector('[draggable]')).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { button, mountAt, text, unmountAll } from './schemaTestKit'
|
||||
import {
|
||||
addField,
|
||||
all,
|
||||
byLabel,
|
||||
click,
|
||||
el,
|
||||
nameInput,
|
||||
pickTile,
|
||||
press,
|
||||
typeInto,
|
||||
} from './builderKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
const writeText = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
writeText.mockReset().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await unmountAll()
|
||||
})
|
||||
|
||||
const toggle = () => el('[data-testid="api-preview-toggle"]')!
|
||||
const json = () => el('[data-testid="api-json"]')!.textContent!
|
||||
|
||||
async function openPreview() {
|
||||
await click(toggle())
|
||||
}
|
||||
|
||||
describe('API preview (design state 10)', () => {
|
||||
it('shows the collapsed bar with the first route and Copy JSON, key falling back to events', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
const bar = text(el('[data-testid="api-preview"]'))
|
||||
expect(bar).toContain('API preview')
|
||||
expect(bar).toContain('GET /v1/content-type/events/entry')
|
||||
expect(bar).toContain('Copy JSON')
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('opens on the five routes, the sample entry and the hints', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Press mentions')
|
||||
|
||||
await openPreview()
|
||||
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('true')
|
||||
const panel = text(el('[data-testid="api-preview"]'))
|
||||
expect(panel).toContain('Where entries of this type live')
|
||||
expect(all('[data-testid="api-route"]').map(text)).toEqual([
|
||||
'GET /v1/content-type/press-mentions/entry',
|
||||
'POST /v1/content-type/press-mentions/entry',
|
||||
'GET /v1/content-type/press-mentions/entry/{id}',
|
||||
'PUT /v1/content-type/press-mentions/entry/{id}',
|
||||
'DELETE /v1/content-type/press-mentions/entry/{id}',
|
||||
])
|
||||
expect(panel).toContain("A sample entry. Values come from each field's type and default.")
|
||||
expect(panel).toContain(
|
||||
'Updates as you edit. Fields you add or remove here appear after you save.',
|
||||
)
|
||||
expect(JSON.parse(json())).toEqual({
|
||||
id: 42,
|
||||
content_type: 'press-mentions',
|
||||
data: { title: 'Example text' },
|
||||
created_by: 3,
|
||||
updated_by: 3,
|
||||
created_at: '2026-10-15T11:02:00Z',
|
||||
updated_at: '2026-10-15T11:02:00Z',
|
||||
})
|
||||
expect(json()).toContain('\n "id": 42,')
|
||||
})
|
||||
|
||||
it('updates on every edit, reorder and removal', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await openPreview()
|
||||
|
||||
await addField('Online')
|
||||
await pickTile('Yes or no')
|
||||
expect(Object.keys(JSON.parse(json()).data)).toEqual(['title', 'online'])
|
||||
expect(JSON.parse(json()).data.online).toBe(false)
|
||||
|
||||
await press(byLabel('Move Online'), ' ')
|
||||
await press(byLabel('Move Online'), 'ArrowUp')
|
||||
await press(byLabel('Move Online'), ' ')
|
||||
expect(Object.keys(JSON.parse(json()).data)).toEqual(['online', 'title'])
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
expect(Object.keys(JSON.parse(json()).data)).toEqual(['title'])
|
||||
})
|
||||
|
||||
it('skips a field whose key is invalid', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await openPreview()
|
||||
|
||||
await click(button('Add field'))
|
||||
|
||||
expect(Object.keys(JSON.parse(json()).data)).toEqual(['title'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copy JSON', () => {
|
||||
it('writes the exact sample, reads Copied for 2 seconds, then Copy JSON again', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await openPreview()
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
|
||||
await click(el('[data-testid="api-copy"]'))
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(json())
|
||||
expect(text(el('[data-testid="api-copy"]'))).toBe('Copied')
|
||||
|
||||
vi.advanceTimersByTime(1999)
|
||||
await flushPromises()
|
||||
expect(text(el('[data-testid="api-copy"]'))).toBe('Copied')
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(text(el('[data-testid="api-copy"]'))).toBe('Copy JSON')
|
||||
})
|
||||
})
|
||||
|
||||
describe('API preview on mobile', () => {
|
||||
it('opens a sheet with the route, the JSON and a full-width Copy JSON that copies', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
|
||||
await click(el('[data-testid="api-row"]'))
|
||||
|
||||
expect(text(el('[role="dialog"]'))).toContain('API preview')
|
||||
expect(text(el('[data-testid="api-sheet-route"]'))).toBe(
|
||||
'GET /v1/content-type/events/entry',
|
||||
)
|
||||
const shown = el('[data-testid="api-sheet-json"]')!.textContent!
|
||||
expect(JSON.parse(shown).content_type).toBe('events')
|
||||
const copy = el('[data-testid="api-sheet-copy"]')!
|
||||
expect(copy.className).toContain('flex-1')
|
||||
expect(text(copy)).toBe('Copy JSON')
|
||||
|
||||
await click(copy)
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(shown)
|
||||
expect(text(copy)).toBe('Copied')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,487 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import router from '@/router'
|
||||
import BuilderActions from '@/components/BuilderActions.vue'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import {
|
||||
button,
|
||||
calls,
|
||||
createdType,
|
||||
failWithBody,
|
||||
mountAt,
|
||||
networkFailure,
|
||||
pending,
|
||||
posts,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './schemaTestKit'
|
||||
import {
|
||||
addField,
|
||||
all,
|
||||
buildEvents,
|
||||
byLabel,
|
||||
chip,
|
||||
click,
|
||||
discardButton,
|
||||
el,
|
||||
keyField,
|
||||
keyInput,
|
||||
labelInput,
|
||||
main,
|
||||
nameInput,
|
||||
rowLabels,
|
||||
saveButton,
|
||||
typeInto,
|
||||
} from './builderKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = () => undefined
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await unmountAll()
|
||||
})
|
||||
|
||||
const SAVE_FAILED_TITLE = "Couldn't save the content type"
|
||||
const SAVE_FAILED_TEXT =
|
||||
'Something went wrong on our side. Your changes are still here. Try again in a moment.'
|
||||
|
||||
const EVENTS_BODY = {
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
fields: [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
default: null,
|
||||
min: null,
|
||||
max: 80,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'starts_on',
|
||||
label: 'Starts on',
|
||||
type: 'date',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: '2026-01-01',
|
||||
max: '2027-12-31',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: 'Category',
|
||||
type: 'select',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
options: ['Talk', 'Workshop', 'Meetup'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe('Saving a valid type', () => {
|
||||
it('posts once the exact body, in list order, with no icon', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await buildEvents()
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.url).toBe('/v1/content-type')
|
||||
expect(posts[0]!.body).toEqual(EVENTS_BODY)
|
||||
expect('icon' in posts[0]!.body).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saving (design state 11, create)', () => {
|
||||
async function startSaving() {
|
||||
const gate = pending()
|
||||
await mountAt('/schema/new', { create: () => gate.promise })
|
||||
await buildEvents()
|
||||
await click(saveButton())
|
||||
return gate
|
||||
}
|
||||
|
||||
it('shows the chip with a loader, dims and disables the form, and sends one request only', async () => {
|
||||
await startSaving()
|
||||
|
||||
expect(chip()).toBe('Saving…')
|
||||
expect(el('[data-testid="builder-chip"] svg')).not.toBeNull()
|
||||
expect(saveButton()).toBeUndefined()
|
||||
const saving = button('Saving…') as HTMLButtonElement
|
||||
expect(saving.disabled).toBe(true)
|
||||
expect(saving.querySelector('svg')).not.toBeNull()
|
||||
expect(discardButton()!.disabled).toBe(true)
|
||||
expect(nameInput()!.disabled).toBe(true)
|
||||
expect(keyInput()!.disabled).toBe(true)
|
||||
expect(labelInput()!.disabled).toBe(true)
|
||||
expect((button('Add field') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(all<HTMLButtonElement>('[data-grip]').every((grip) => grip.disabled)).toBe(true)
|
||||
expect((byLabel('Remove') as HTMLButtonElement).disabled).toBe(true)
|
||||
|
||||
saving.click()
|
||||
await flushPromises()
|
||||
expect(posts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('leaves the form editable again once the request settles', async () => {
|
||||
const gate = await startSaving()
|
||||
gate.resolve(createdType(posts[0]!.body))
|
||||
await flushPromises()
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Double activation while saving', () => {
|
||||
it('sends one POST however often Save is activated programmatically', async () => {
|
||||
const gate = pending()
|
||||
const mounted = await mountAt('/schema/new', { create: () => gate.promise })
|
||||
await buildEvents()
|
||||
await click(saveButton())
|
||||
const actions = mounted.findComponent(BuilderActions)
|
||||
|
||||
actions.vm.$emit('save')
|
||||
actions.vm.$emit('save')
|
||||
await flushPromises()
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
expect((button('Saving…') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(actions.props('canSave')).toBe(false)
|
||||
expect(actions.props('canReveal')).toBe(false)
|
||||
|
||||
gate.resolve(createdType(posts[0]!.body))
|
||||
await flushPromises()
|
||||
expect(posts).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Created type reaching the store', () => {
|
||||
it('inserts the new type before the first later name and needs no second list request', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Newsletters')
|
||||
|
||||
await click(saveButton())
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
await flushPromises()
|
||||
|
||||
expect(useContentTypesStore().items.map((type) => type.name)).toEqual([
|
||||
'Events',
|
||||
'Newsletters',
|
||||
'Recipes',
|
||||
'Press mentions',
|
||||
])
|
||||
expect(el('nav a[href="/content/newsletters"]')).not.toBeNull()
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saved (design state 12, create)', () => {
|
||||
it('toasts, adds the type to the store and the sidebar, replaces the URL and does not prompt', async () => {
|
||||
await mountAt('/schema/new', {
|
||||
create: (body) => ({ ...createdType(body), fields: new Array(6) }),
|
||||
})
|
||||
await buildEvents()
|
||||
|
||||
await click(saveButton())
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).toContain('Content type saved')
|
||||
expect(text(document.body)).toContain('Events now has 6 fields.')
|
||||
expect(text(document.body)).not.toContain('Leave without saving?')
|
||||
expect(el('nav a[href="/content/events"]')).not.toBeNull()
|
||||
const listed = all('[data-testid="schema-row"]').map((row) => text(row))
|
||||
expect(listed.some((row) => row.includes('Events'))).toBe(true)
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
router.back()
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).not.toBe('/schema/new')
|
||||
})
|
||||
|
||||
it('reads "1 field" for a single field', async () => {
|
||||
await mountAt('/schema/new', {
|
||||
create: (body) => ({ ...createdType(body), fields: new Array(1) }),
|
||||
})
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(saveButton())
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).toContain('Events now has 1 field.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigation failing after a created type', () => {
|
||||
async function saveWithBrokenNavigation() {
|
||||
vi.spyOn(router, 'replace').mockRejectedValue(new Error('chunk load failed'))
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await click(saveButton())
|
||||
}
|
||||
|
||||
it('keeps the type, shows no failure and blocks a second POST', async () => {
|
||||
await saveWithBrokenNavigation()
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(text(document.body)).not.toContain(SAVE_FAILED_TITLE)
|
||||
expect(chip()).toBe('')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(el('nav a[href="/content/events"]')).not.toBeNull()
|
||||
expect(text(document.body)).toContain('Content type saved')
|
||||
})
|
||||
|
||||
it('treats the form as editable again after the next edit', async () => {
|
||||
await saveWithBrokenNavigation()
|
||||
|
||||
await typeInto(nameInput(), 'Events 2')
|
||||
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('A failed save then an edit', () => {
|
||||
it('returns to editing so Save works again', async () => {
|
||||
let attempts = 0
|
||||
await mountAt('/schema/new', {
|
||||
create: (body) => {
|
||||
attempts++
|
||||
if (attempts === 1) {
|
||||
throw new Error('boom')
|
||||
}
|
||||
return createdType(body)
|
||||
},
|
||||
})
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await click(saveButton())
|
||||
expect(chip()).toBe('Save failed')
|
||||
|
||||
await typeInto(nameInput(), 'Events 2')
|
||||
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Key already taken (design state 14)', () => {
|
||||
async function reachTaken() {
|
||||
await mountAt('/schema/new', {
|
||||
create: failWithBody(409, 'content_type_key_taken'),
|
||||
})
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await click(saveButton())
|
||||
}
|
||||
|
||||
it('shows the message under Key, makes it editable with focus, and counts one problem', async () => {
|
||||
await reachTaken()
|
||||
|
||||
expect(text(keyField().querySelector('[role="alert"]'))).toBe(
|
||||
'The key "events" is already used by another content type. Choose a different key.',
|
||||
)
|
||||
expect(keyInput()!.readOnly).toBe(false)
|
||||
expect(document.activeElement).toBe(keyInput())
|
||||
expect(text(keyField())).not.toContain('Auto')
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
expect(rowLabels()).toEqual(['Title'])
|
||||
})
|
||||
|
||||
it('clears the error and enables Save when the key is edited', async () => {
|
||||
await reachTaken()
|
||||
|
||||
await typeInto(keyInput(), 'events-2')
|
||||
|
||||
expect(keyField().querySelector('[role="alert"]')).toBeNull()
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The server refuses a key or a field', () => {
|
||||
it('shows the key message under Key for 400 invalid_content_type_key', async () => {
|
||||
await mountAt('/schema/new', {
|
||||
create: failWithBody(400, 'invalid_content_type_key'),
|
||||
})
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(keyField().querySelector('[role="alert"]'))).toBe(
|
||||
'Use 2 to 40 lowercase letters, digits or hyphens.',
|
||||
)
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
})
|
||||
|
||||
it('shows the banner for 422 invalid_field and keeps the edits and Save', async () => {
|
||||
await mountAt('/schema/new', { create: failWithBody(422, 'invalid_field') })
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(main())).toContain('Check the fields marked in the list.')
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the access page for 403', async () => {
|
||||
await mountAt('/schema/new', { create: failWithBody(403, 'Only owners and admins') })
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(main())).toContain("You don't have access to Schema")
|
||||
expect(nameInput()).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the redirect to the API client for 401', async () => {
|
||||
await mountAt('/schema/new', { create: failWithBody(401, '') })
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(main())).not.toContain(SAVE_FAILED_TITLE)
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Save failed (design state 13)', () => {
|
||||
it.each([
|
||||
['answers 500', failWithBody(500, 'boom')],
|
||||
['fails (network)', networkFailure],
|
||||
])(
|
||||
'shows the alert, keeps every edit and retries with the same body when the POST %s',
|
||||
async (_, failure) => {
|
||||
let respond = failure
|
||||
await mountAt('/schema/new', { create: (body) => respond() ?? body })
|
||||
await buildEvents()
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(main())).toContain(SAVE_FAILED_TITLE)
|
||||
expect(text(main())).toContain(SAVE_FAILED_TEXT)
|
||||
expect(chip()).toBe('Save failed')
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
expect(rowLabels()).toEqual(['Title', 'Starts on', 'Category'])
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
|
||||
respond = () => undefined
|
||||
const first = posts[0]!.body
|
||||
await click(button('Try again'))
|
||||
|
||||
expect(posts).toHaveLength(2)
|
||||
expect(posts[1]!.body).toEqual(first)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Offline (design state 16)', () => {
|
||||
function setOnline(value: boolean) {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(value)
|
||||
window.dispatchEvent(new Event(value ? 'online' : 'offline'))
|
||||
}
|
||||
|
||||
it('shows the banner and the chip, disables Save, keeps edits, and sends nothing on reconnect', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
setOnline(false)
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).toContain(
|
||||
"You're offline. Changes will be saved when you reconnect.",
|
||||
)
|
||||
expect(chip()).toBe('Waiting for connection')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
|
||||
setOnline(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).not.toContain("You're offline.")
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
expect(posts).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Discard', () => {
|
||||
it('resets to the blank form, sends nothing and keeps the URL', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await buildEvents()
|
||||
|
||||
await click(discardButton())
|
||||
|
||||
expect(nameInput()!.value).toBe('')
|
||||
expect(keyInput()!.value).toBe('')
|
||||
expect(rowLabels()).toEqual(['Title'])
|
||||
expect(chip()).toBe('')
|
||||
expect(discardButton()!.disabled).toBe(true)
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(posts).toHaveLength(0)
|
||||
expect(router.currentRoute.value.path).toBe('/schema/new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Leaving with unsaved edits', () => {
|
||||
const DIALOG_TITLE = 'Leave without saving?'
|
||||
|
||||
it('navigates without a prompt from a pristine form', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await router.push('/schema')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
expect(text(document.body)).not.toContain(DIALOG_TITLE)
|
||||
})
|
||||
|
||||
it('asks first when something was edited, and stays on Keep editing', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
const leaving = router.push('/schema')
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).toContain(DIALOG_TITLE)
|
||||
expect(button('Keep editing')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(button('Discard changes')).toBeInstanceOf(HTMLButtonElement)
|
||||
await click(button('Keep editing'))
|
||||
await leaving
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/schema/new')
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
})
|
||||
|
||||
it('leaves on Discard changes', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Starts on')
|
||||
|
||||
const leaving = router.push('/schema')
|
||||
await flushPromises()
|
||||
await click(button('Discard changes'))
|
||||
await leaving
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { button, mountAt, text, unmountAll } from './schemaTestKit'
|
||||
import { byLabel, click, el, labelInput, rowLabels, rows, typeInto } from './builderKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
const dialog = () => el('[role="dialog"]')
|
||||
|
||||
describe('Selecting a field on mobile', () => {
|
||||
it('opens a sheet titled with the field, with the inspector, the move and remove buttons and Done', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
|
||||
await click(rows()[0]!.querySelector('[data-field-row]'))
|
||||
|
||||
expect(text(el('[data-testid="field-sheet-title"]'))).toBe('Field: Title')
|
||||
expect(labelInput()!.value).toBe('Title')
|
||||
expect(byLabel('Move up')).not.toBeNull()
|
||||
expect(byLabel('Move down')).not.toBeNull()
|
||||
expect(byLabel('Remove')).not.toBeNull()
|
||||
expect(button('Done')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(document.activeElement).toBe(el('[data-testid="field-sheet-title"]'))
|
||||
})
|
||||
|
||||
it('closes with Done and returns the focus to the row', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
const row = rows()[0]!.querySelector<HTMLElement>('[data-field-row]')!
|
||||
await click(row)
|
||||
|
||||
await click(button('Done'))
|
||||
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(document.activeElement).toBe(row)
|
||||
})
|
||||
|
||||
it('updates the title with the label and removes the field from the sheet', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
await click(rows()[0]!.querySelector('[data-field-row]'))
|
||||
|
||||
await typeInto(labelInput(), 'Headline')
|
||||
expect(text(el('[data-testid="field-sheet-title"]'))).toBe('Field: Headline')
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(rowLabels()).toEqual([])
|
||||
})
|
||||
|
||||
it('opens the sheet on a new field with the label focused', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
|
||||
await click(button('Add field'))
|
||||
|
||||
expect(text(el('[data-testid="field-sheet-title"]'))).toBe('Field: Untitled field')
|
||||
expect(rowLabels()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import router from '@/router'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { button, calls, failWith, mountAt, text, unmountAll } from './schemaTestKit'
|
||||
import {
|
||||
all,
|
||||
chip,
|
||||
click,
|
||||
discardButton,
|
||||
el,
|
||||
inspector,
|
||||
keyField,
|
||||
keyInput,
|
||||
labelInput,
|
||||
main,
|
||||
nameInput,
|
||||
rowLabels,
|
||||
saveButton,
|
||||
typeInto,
|
||||
} from './builderKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
const NOT_FOUND_TITLE = 'Content type not found'
|
||||
const ACCESS_TITLE = "You don't have access to Schema"
|
||||
|
||||
function newTypeButtons() {
|
||||
return all('button').filter((candidate) => text(candidate) === 'New content type')
|
||||
}
|
||||
|
||||
describe('Reaching the builder', () => {
|
||||
it('opens from the header button', async () => {
|
||||
await mountAt('/schema')
|
||||
|
||||
await click(newTypeButtons()[0])
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
|
||||
expect(nameInput()).not.toBeNull()
|
||||
expect(text(main())).not.toContain(NOT_FOUND_TITLE)
|
||||
})
|
||||
|
||||
it('opens from the empty-state button', async () => {
|
||||
await mountAt('/schema', { content: () => [] })
|
||||
|
||||
await click(newTypeButtons().at(-1))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
|
||||
expect(nameInput()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens from the mobile floating button', async () => {
|
||||
await mountAt('/schema', { width: MOBILE_WIDTH })
|
||||
|
||||
await click(newTypeButtons().find((candidate) => candidate.className.includes('fixed')))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
|
||||
expect(nameInput()).not.toBeNull()
|
||||
})
|
||||
|
||||
it.each(['/schema/events', '/schema/unknown'])('still says not found at %s', async (path) => {
|
||||
await mountAt(path)
|
||||
|
||||
expect(text(main())).toContain(NOT_FOUND_TITLE)
|
||||
expect(nameInput()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles and sessions', () => {
|
||||
it.each(['owner', 'admin'])('shows the builder to a verified %s', async (role) => {
|
||||
await mountAt('/schema/new', { role })
|
||||
|
||||
expect(nameInput()).not.toBeNull()
|
||||
expect(text(main())).not.toContain(ACCESS_TITLE)
|
||||
})
|
||||
|
||||
it('tells a verified user who is neither to go home, leaving the URL', async () => {
|
||||
await mountAt('/schema/new', { role: 'user' })
|
||||
|
||||
expect(text(main())).toContain(ACCESS_TITLE)
|
||||
expect(button('Go to Home')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(router.currentRoute.value.path).toBe('/schema/new')
|
||||
expect(nameInput()).toBeNull()
|
||||
expect(el('[data-testid="field-row"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the same page when the content types answer 403', async () => {
|
||||
await mountAt('/schema/new', { content: failWith(403) })
|
||||
|
||||
expect(text(main())).toContain(ACCESS_TITLE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The builder needs no built-in list', () => {
|
||||
it('calls the custom list once, never the built-in one, and shows no skeleton', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
expect(calls).not.toContain('/v1/builtin-type')
|
||||
expect(nameInput()).not.toBeNull()
|
||||
expect(el('[data-testid="schema-skeleton-row"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the builder while the custom list is still loading', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((done) => (release = done))
|
||||
await mountAt('/schema/new', { content: () => gate.then(() => []) })
|
||||
|
||||
expect(nameInput()).not.toBeNull()
|
||||
release()
|
||||
await flushPromises()
|
||||
})
|
||||
})
|
||||
|
||||
describe('New type, desktop (design state 1)', () => {
|
||||
it('shows the breadcrumb, Name, Key and the list header', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(text(el('nav[aria-label="breadcrumb"]'))).toBe('Schema New content type')
|
||||
expect(el('nav[aria-label="breadcrumb"] a[href="/schema"]')).not.toBeNull()
|
||||
expect(all('nav[aria-label="breadcrumb"] a')).toHaveLength(1)
|
||||
|
||||
expect(nameInput()!.value).toBe('')
|
||||
expect(nameInput()!.placeholder).toBe('For example, Events')
|
||||
expect(nameInput()!.required).toBe(true)
|
||||
expect(nameInput()!.maxLength).toBe(100)
|
||||
expect(keyInput()!.value).toBe('')
|
||||
expect(keyInput()!.placeholder).toBe('events')
|
||||
expect(keyInput()!.readOnly).toBe(true)
|
||||
expect(text(main())).toContain(
|
||||
"Used in URLs and the API. Lowercase letters, digits and hyphens. It can't change after you save.",
|
||||
)
|
||||
expect(text(main())).toContain('Fields')
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('1 of 50')
|
||||
})
|
||||
|
||||
it('shows the starter row and the inspector of Title', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(rowLabels()).toEqual(['Title'])
|
||||
const row = el('[data-testid="field-row"]')!
|
||||
expect(text(row.querySelector('[data-testid="field-row-key"]'))).toBe('title')
|
||||
expect(row.querySelector('[data-field-row]')!.getAttribute('aria-current')).toBe('true')
|
||||
expect(text(row.querySelector('[data-testid="field-row-badges"]'))).toBe('New')
|
||||
expect(button('Add field')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Title')
|
||||
expect(labelInput()!.value).toBe('Title')
|
||||
expect(el<HTMLInputElement>('input[name="field-key"]')!.value).toBe('title')
|
||||
const copy = text(inspector())
|
||||
expect(copy).toContain('Auto')
|
||||
expect(copy).toContain(
|
||||
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
|
||||
)
|
||||
expect(all('[role="radio"]')).toHaveLength(6)
|
||||
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
|
||||
'Text',
|
||||
)
|
||||
expect(copy).toContain("An entry can't be saved with this field empty.")
|
||||
expect(copy).toContain('Unique values')
|
||||
expect(copy).toContain('Used when an entry is created and this field is left out.')
|
||||
expect(copy).toContain('Shortest')
|
||||
expect(copy).toContain('Longest')
|
||||
expect(copy).toContain('Counted in characters. Entries show a counter.')
|
||||
expect(all('[role="switch"]').map((toggle) => toggle.getAttribute('aria-checked'))).toEqual(
|
||||
['false', 'false'],
|
||||
)
|
||||
})
|
||||
|
||||
it('disables Save and Discard, shows no chip, no entries bar and a collapsed API bar', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(discardButton()!.disabled).toBe(true)
|
||||
expect(chip()).toBe('')
|
||||
expect(text(main())).not.toContain('entries use this content type')
|
||||
expect(el('[data-testid="api-preview-toggle"]')!.getAttribute('aria-expanded')).toBe(
|
||||
'false',
|
||||
)
|
||||
expect(el('[data-testid="api-json"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('New type, mobile (design state 1)', () => {
|
||||
it('shows the topbar, stacked Name and Key, the list and the API preview row', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(el('a[href="/schema"][aria-label="Schema"]')).not.toBeNull()
|
||||
expect(text(el('[data-slot="title"]'))).toBe('New content type')
|
||||
expect(nameInput()).not.toBeNull()
|
||||
expect(keyInput()).not.toBeNull()
|
||||
expect(rowLabels()).toEqual(['Title'])
|
||||
expect(text(el('[data-testid="api-row"]'))).toBe('API preview')
|
||||
expect(el('[data-testid="api-row"] svg')).not.toBeNull()
|
||||
expect(inspector()).toBeNull()
|
||||
expect(el('[data-testid="inspector-pane"]')).toBeNull()
|
||||
expect(discardButton()).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(saveButton()).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(el('[data-testid="api-preview"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('The key follows the name until it is edited', () => {
|
||||
it.each([
|
||||
['Events', 'events', 'auto'],
|
||||
['Press mentions', 'press-mentions', 'auto'],
|
||||
['Café & Bar', 'cafe-bar', 'auto'],
|
||||
[' Job openings!', 'job-openings', 'auto'],
|
||||
['2026 Awards', '2026-awards', 'auto'],
|
||||
['!!!', '', 'empty'],
|
||||
['x'.repeat(60), 'x'.repeat(40), 'auto'],
|
||||
])('%j gives the key %j', async (name, key, mode) => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await typeInto(nameInput(), name)
|
||||
|
||||
expect(keyInput()!.value).toBe(key)
|
||||
expect(keyInput()!.readOnly).toBe(true)
|
||||
expect(text(keyField()).includes('Auto')).toBe(mode === 'auto')
|
||||
})
|
||||
|
||||
it('unlocks with the pencil, keeps a typed key, and resets to the name', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(keyField().querySelector('button[aria-label="Edit key"]'))
|
||||
|
||||
expect(keyInput()!.readOnly).toBe(false)
|
||||
expect(text(keyField())).not.toContain('Auto')
|
||||
expect(button('Reset to match the name')).toBeInstanceOf(HTMLButtonElement)
|
||||
|
||||
await typeInto(keyInput(), 'agenda')
|
||||
await typeInto(nameInput(), 'Events and talks')
|
||||
expect(keyInput()!.value).toBe('agenda')
|
||||
|
||||
await click(button('Reset to match the name'))
|
||||
|
||||
expect(keyInput()!.value).toBe('events-and-talks')
|
||||
expect(keyInput()!.readOnly).toBe(true)
|
||||
expect(text(keyField())).toContain('Auto')
|
||||
})
|
||||
})
|
||||
@@ -178,7 +178,7 @@ describe('Schema built-in view: loading', () => {
|
||||
})
|
||||
|
||||
describe('Schema built-in view: unknown key', () => {
|
||||
it.each(['/schema/unknown', '/schema/events', '/schema/new'])(
|
||||
it.each(['/schema/unknown', '/schema/events'])(
|
||||
'shows the not-found page at %s without redirecting',
|
||||
async (path) => {
|
||||
await mountAt(path)
|
||||
@@ -204,7 +204,7 @@ describe('Schema built-in view: error', () => {
|
||||
expect(text(main())).toContain(
|
||||
'GET /v1/builtin-type failed. Check your connection and try again.',
|
||||
)
|
||||
expect(button('Try again')).toBeDefined()
|
||||
expect(button('Try again')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(document.body.querySelector('[data-testid="schema-field-row"]')).toBeNull()
|
||||
|
||||
respond = () => builtinTypes
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { button, text } from './schemaTestKit'
|
||||
|
||||
export function el<T extends HTMLElement = HTMLElement>(selector: string) {
|
||||
return document.body.querySelector<T>(selector)
|
||||
}
|
||||
|
||||
export function all<T extends HTMLElement = HTMLElement>(selector: string) {
|
||||
return [...document.body.querySelectorAll<T>(selector)]
|
||||
}
|
||||
|
||||
export async function typeInto(
|
||||
input: HTMLInputElement | HTMLTextAreaElement | null | undefined,
|
||||
value: string,
|
||||
) {
|
||||
input!.value = value
|
||||
input!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function press(target: Element | null | undefined, key: string) {
|
||||
target!.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function click(target: Element | null | undefined) {
|
||||
;(target as HTMLElement).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export const nameInput = () => el<HTMLInputElement>('input[name="name"]')
|
||||
export const keyInput = () => el<HTMLInputElement>('input[name="key"]')
|
||||
export const keyField = () => keyInput()!.closest<HTMLElement>('[data-orientation]')!
|
||||
export const labelInput = () => el<HTMLInputElement>('input[name="label"]')
|
||||
export const fieldKeyInput = () => el<HTMLInputElement>('input[name="field-key"]')
|
||||
export const saveButton = () => button('Save') as HTMLButtonElement | undefined
|
||||
export const discardButton = () => button('Discard') as HTMLButtonElement | undefined
|
||||
export const byLabel = (label: string) => el<HTMLButtonElement>(`[aria-label="${label}"]`)
|
||||
export const rows = () => all('[data-testid="field-row"]')
|
||||
export const rowLabels = () =>
|
||||
rows().map((row) => text(row.querySelector('[data-testid="field-row-label"]')))
|
||||
export const chip = () => text(el('[data-testid="builder-chip"]'))
|
||||
export const main = () => el('#dashboard-content')!
|
||||
export const inspector = () => el('[data-testid="field-inspector"]')
|
||||
export const live = () => text(el('[data-testid="reorder-live"]'))
|
||||
|
||||
export async function addField(label: string) {
|
||||
await click(button('Add field'))
|
||||
await typeInto(labelInput(), label)
|
||||
}
|
||||
|
||||
export async function selectRow(label: string) {
|
||||
const row = rows().find(
|
||||
(candidate) => text(candidate.querySelector('[data-testid="field-row-label"]')) === label,
|
||||
)
|
||||
await click(row!.querySelector('[data-field-row]'))
|
||||
}
|
||||
|
||||
export function tile(name: string) {
|
||||
return all('[role="radio"]').find((radio) =>
|
||||
text(radio.closest('label')).startsWith(name),
|
||||
) as HTMLButtonElement
|
||||
}
|
||||
|
||||
export async function pickTile(name: string) {
|
||||
await click(tile(name))
|
||||
}
|
||||
|
||||
export function switchOf(label: string) {
|
||||
const found = all('[data-testid="field-inspector"] label').find((candidate) =>
|
||||
text(candidate).startsWith(label),
|
||||
)
|
||||
return document.getElementById(found!.getAttribute('for')!) as HTMLButtonElement
|
||||
}
|
||||
|
||||
export async function toggle(label: string) {
|
||||
await click(switchOf(label))
|
||||
}
|
||||
|
||||
export const limit = (which: 'min' | 'max') => el<HTMLInputElement>(`input[name="${which}"]`)
|
||||
export const defaultInput = () => el<HTMLInputElement>('input[name="default"]')
|
||||
export const addChoiceInput = () => el<HTMLInputElement>('input[aria-label="Add a choice"]')
|
||||
|
||||
export async function addChoice(value: string) {
|
||||
await typeInto(addChoiceInput(), value)
|
||||
await click(button('Add'))
|
||||
}
|
||||
|
||||
export function choiceValues() {
|
||||
return all<HTMLInputElement>('[data-testid="choice-row"] input').map((input) => input.value)
|
||||
}
|
||||
|
||||
export async function buildEvents() {
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await toggle('Required')
|
||||
await toggle('Unique values')
|
||||
await typeInto(limit('max'), '80')
|
||||
await addField('Starts on')
|
||||
await pickTile('Date')
|
||||
await typeInto(limit('min'), '2026-01-01')
|
||||
await typeInto(limit('max'), '2027-12-31')
|
||||
await addField('Category')
|
||||
await pickTile('Choice')
|
||||
await addChoice('Talk')
|
||||
await addChoice('Workshop')
|
||||
await addChoice('Meetup')
|
||||
}
|
||||
@@ -76,6 +76,16 @@ export const builtinTypes = [
|
||||
fields: key === 'articles' ? articleFields : new Array(count),
|
||||
}))
|
||||
|
||||
export function createdType(body: Record<string, unknown>) {
|
||||
return {
|
||||
id: 4,
|
||||
icon: null,
|
||||
created_at: '2026-10-19T09:30:00Z',
|
||||
updated_at: '2026-10-19T09:30:00Z',
|
||||
...body,
|
||||
}
|
||||
}
|
||||
|
||||
export function manyTypes(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
...customTypes[0]!,
|
||||
@@ -92,12 +102,24 @@ export function failWith(status: number): Responder {
|
||||
}
|
||||
}
|
||||
|
||||
export function failWithBody(status: number, body: string): Responder {
|
||||
return () => {
|
||||
throw new ApiError(status, undefined, body)
|
||||
}
|
||||
}
|
||||
|
||||
export const networkFailure: Responder = () => {
|
||||
throw new NetworkError()
|
||||
}
|
||||
|
||||
export interface PostedRequest {
|
||||
url: string
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface SchemaMount {
|
||||
role?: string
|
||||
create?: (body: PostedRequest['body']) => unknown
|
||||
width?: number
|
||||
content?: Responder
|
||||
builtin?: Responder
|
||||
@@ -107,9 +129,11 @@ export interface SchemaMount {
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
export const calls: string[] = []
|
||||
export const posts: PostedRequest[] = []
|
||||
|
||||
export async function mountAt(path: string, options: SchemaMount = {}, shellOnly = false) {
|
||||
calls.length = 0
|
||||
posts.length = 0
|
||||
localStorage.clear()
|
||||
setViewportWidth(options.width ?? DESKTOP_WIDTH)
|
||||
const pinia = createPinia()
|
||||
@@ -130,10 +154,15 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
role: (options.role ?? 'owner') as never,
|
||||
}
|
||||
session.status = 'ready'
|
||||
const request = vi.fn(async (url: string) => {
|
||||
const request = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'POST') {
|
||||
const body = JSON.parse(String(init.body)) as PostedRequest['body']
|
||||
posts.push({ url, body })
|
||||
return (options.create ?? (() => createdType(body)))(body)
|
||||
}
|
||||
calls.push(url)
|
||||
if (url === '/v1/content-type') {
|
||||
return (options.content ?? (() => customTypes))()
|
||||
return (options.content ?? (() => [...customTypes]))()
|
||||
}
|
||||
if (url === '/v1/builtin-type') {
|
||||
return (options.builtin ?? (() => builtinTypes))()
|
||||
|
||||
@@ -44,7 +44,34 @@ const PAGINATION_STATES = [
|
||||
'Long labels on mobile',
|
||||
]
|
||||
|
||||
const schemaBoard = 'RenewCMS Screens Schema.dc.html'
|
||||
|
||||
const extraSources: Record<string, Source[]> = {
|
||||
schema: [
|
||||
{
|
||||
board: schemaBoard,
|
||||
group: '2 Schema builder',
|
||||
states: [
|
||||
'New type',
|
||||
'Editing an existing type',
|
||||
'Locked key: tooltip',
|
||||
'Unsaved changes',
|
||||
'Field errors',
|
||||
'Type-level errors',
|
||||
'Field key error',
|
||||
'Choice list editor',
|
||||
'Keyboard reorder',
|
||||
'API preview open',
|
||||
'Saving',
|
||||
'Saved',
|
||||
'Save failed',
|
||||
'Key already taken',
|
||||
'Schema incompatible',
|
||||
'Offline',
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
],
|
||||
sites: [
|
||||
{
|
||||
board: officeBoard,
|
||||
|
||||
@@ -6515,6 +6515,21 @@ function schemaApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiM
|
||||
}
|
||||
}
|
||||
|
||||
function schemaIsMobile(page: Page) {
|
||||
return (page.viewportSize()?.width ?? 0) < 768
|
||||
}
|
||||
|
||||
async function schemaOpenRow(page: Page, label: string) {
|
||||
await page.locator('[data-field-row]', { hasText: label }).first().click()
|
||||
}
|
||||
|
||||
async function schemaCloseSheet(page: Page) {
|
||||
if (schemaIsMobile(page)) {
|
||||
await page.getByRole('button', { name: 'Done' }).click()
|
||||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||||
}
|
||||
}
|
||||
|
||||
const schemaStates: Record<string, VisualState> = {
|
||||
Populated: { path: '/schema', api: schemaApi() },
|
||||
Loading: {
|
||||
@@ -6536,6 +6551,56 @@ const schemaStates: Record<string, VisualState> = {
|
||||
}),
|
||||
},
|
||||
'Built-in type: read-only view': { path: '/schema/articles', api: schemaApi() },
|
||||
'New type': { path: '/schema/new', api: schemaApi() },
|
||||
'Editing an existing type': { path: '/schema/events', skip: 'pending' },
|
||||
'Locked key: tooltip': { path: '/schema/events', skip: 'pending' },
|
||||
'Unsaved changes': { path: '/schema/events', skip: 'pending' },
|
||||
'Field errors': { path: '/schema/events', skip: 'pending' },
|
||||
'Type-level errors': {
|
||||
path: '/schema/new',
|
||||
api: schemaApi(),
|
||||
steps: async (page) => {
|
||||
await page.getByPlaceholder('For example, Events').fill('x')
|
||||
await page.getByRole('button', { name: 'Edit key' }).first().click()
|
||||
await page.locator('input[name="key"]').fill('e')
|
||||
await page.getByPlaceholder('For example, Events').fill('')
|
||||
await schemaOpenRow(page, 'Title')
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
await page.getByText('3 problems to fix').first().waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Field key error': { path: '/schema/new', skip: 'pending' },
|
||||
'Choice list editor': { path: '/schema/events', skip: 'pending' },
|
||||
'Keyboard reorder': { path: '/schema/events', skip: 'pending' },
|
||||
'API preview open': { path: '/schema/events', skip: 'pending' },
|
||||
Saving: { path: '/schema/events', skip: 'pending' },
|
||||
Saved: { path: '/schema/events', skip: 'pending' },
|
||||
'Save failed': { path: '/schema/events', skip: 'pending' },
|
||||
'Key already taken': {
|
||||
path: '/schema/new',
|
||||
api: schemaApi({
|
||||
'POST /v1/content-type': { status: 409, body: 'content_type_key_taken' },
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await page.getByPlaceholder('For example, Events').fill('Events')
|
||||
await schemaOpenRow(page, 'Title')
|
||||
await page.getByRole('switch', { name: 'Required' }).click()
|
||||
await schemaCloseSheet(page)
|
||||
await page.getByRole('button', { name: 'Add field' }).click()
|
||||
await page.locator('input[name="label"]').fill('Starts on')
|
||||
await page.locator('label:has([role="radio"][value="date"])').click()
|
||||
await schemaCloseSheet(page)
|
||||
if (!schemaIsMobile(page)) {
|
||||
await schemaOpenRow(page, 'Title')
|
||||
}
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await page.getByText('is already used by another content type').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Schema incompatible': { path: '/schema/events', skip: 'pending' },
|
||||
Offline: { path: '/schema/events', skip: 'pending' },
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
|
||||
Reference in new issue
Block a user