feat: schema builder's create flow
This commit is contained in:
1 parent
f514146fed
commit
083cc9e23e
71 files changed
+7223
-61
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.
|
||||
|
||||
Reference in new issue
Block a user