381 lines
28 KiB
Gherkin
381 lines
28 KiB
Gherkin
Feature: Relation field in the SPA
|
||
As an owner or admin building a content type, and as a verified user filling an entry
|
||
I want a Relation field that links an entry to entries of another content type
|
||
So that structured content can point at other structured content
|
||
|
||
Background:
|
||
Given the SPA calls the API with relative URLs under /v1 and the "jwt" HttpOnly cookie
|
||
And API errors are plain text
|
||
And this slice changes only web/ (and web/visual); the backend of features/relation_field_type.feature is done
|
||
And the design is docs/design/screens/schema-later.md section 7 (3 states) and the board group "7 Relations" of "RenewCMS Screens Schema.dc.html" with SchemaFrame.dc.html; the frame markup wins over prose for layout
|
||
And the registry is lib/fieldTypes with lib/entryEditors, the single source for the builder, the list cells and the entry form; no screen branches on the type name "relation" where a registry property can say it
|
||
And the tags "Later" and "Relation columns: later" / "Relation field: later" drawn on the board are not rendered; the feature ships
|
||
And a relation value is an id (uint32 of at least 1) or null for one_to_one and one_to_many, and an array of ids ([] when empty, at most 50) for many_to_many
|
||
And a field loaded from the API has "relation": {"content_type","kind"} with kind "one_to_one", "one_to_many" or "many_to_many"
|
||
And every entry answer (POST 201, PUT 200, GET one, each list item) carries a top-level "relations" object keyed by relation field key: {"id","content_type","first_field":{"key","type","value"}} for an existing target, or {"id","missing":true}; an array of these for many_to_many; the label of a target is its first_field formatted by the same rule as the entry list, and "Entry N" when null
|
||
And the API refuses with 422 "invalid_entry: <key>: required", "invalid_entry: <key>: type", 409 "invalid_entry: <key>: unique", 409 "schema_incompatible" for a new required relation on a type with entries, 422 "invalid_field" for a bad definition, and 409 "content_type_referenced" when deleting a target type
|
||
And the picker searches GET /v1/content-type/{target}/entry?q=&page=&size= and reads total from the answer
|
||
And the inverse side is shown in the builder preview only; the target type's form, list and API are unchanged
|
||
And sentence case, buttons are verbs, 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
|
||
And the sample data is the board's: Event with Venue (one_to_many) and Speakers (many_to_many); venues "Rue Verte hall" (Lyon · 120 seats), "Rue Verte yard" (Lyon · outdoor), "Rue du Port studio" (Nantes · 24 seats); speakers "Ines Duarte" and "Jonas Pereira"; entries 42 "Lime plaster workshop", 41 "Heat pumps: what the numbers say", 40 "Open day at the Rue Verte site"
|
||
|
||
Scenario Outline: Relation in the registry
|
||
Given the registry entry for "relation"
|
||
Then <property> is <value>
|
||
|
||
Examples:
|
||
| property | value |
|
||
| type name in the API | relation (the eighth entry, after media) |
|
||
| label | Relation |
|
||
| description | Links to another type's entries (proposed copy) |
|
||
| icon | lucide:link |
|
||
| control | RelationField |
|
||
| pairs with a neighbour | no, full width |
|
||
| empty value | null for one_to_one and one_to_many, [] for many_to_many |
|
||
| initial on create | the empty value; a relation has no default, so no "Default" tag ever shows |
|
||
| initial on edit | the stored id or ids |
|
||
| supports unique | no |
|
||
| supports limits | no |
|
||
| supports default | no |
|
||
| supports choices | no |
|
||
| counter | none |
|
||
| list cell | chips: one chip, two chips and "+N", or "–"; not sortable |
|
||
| sample in the API preview | null for a single kind, [] for many_to_many |
|
||
|
||
Scenario: A loaded relation field is not shown as Text
|
||
Given GET /v1/content-type/events returns a field {"key":"venue","label":"Venue","type":"relation","required":false,"unique":false,"default":null,"min":null,"max":null,"options":[],"relation":{"content_type":"venues","kind":"one_to_many"}}
|
||
Then the builder, the entry form and the list resolve it to the relation entry of the registry, never to the fallback type "text"
|
||
|
||
Scenario Outline: Client validation of a relation value
|
||
Given a <kind> relation "Venue" that is <required>
|
||
When the value is <value>
|
||
Then the rule is <rule>
|
||
|
||
Examples:
|
||
| kind | required | value | rule |
|
||
| single | required | null | required |
|
||
| single | optional | null | none |
|
||
| single | required | 3 | none |
|
||
| single | optional | 0 | type |
|
||
| single | optional | "3" | type |
|
||
| single | optional | [3] | type |
|
||
| many_to_many | required | [] | required |
|
||
| many_to_many | optional | [] | none |
|
||
| many_to_many | optional | [11, 12] | none |
|
||
| many_to_many | optional | [11, 11] | type |
|
||
| many_to_many | optional | 11 | type |
|
||
| many_to_many | optional | 51 ids | type |
|
||
|
||
Scenario Outline: Messages of a relation field
|
||
Given a relation field labelled "Venue" whose related type is named "Venues"
|
||
Then the message of the rule <rule> reads <message>
|
||
|
||
Examples:
|
||
| rule | message |
|
||
| required | Choose a Venue. (proposed copy) |
|
||
| type | Venue must point to an existing entry. (proposed copy) |
|
||
| unique | That Venue is already used by another entry. (proposed copy) |
|
||
| min | not produced for relation |
|
||
| max | not produced for relation |
|
||
| options | not produced for relation |
|
||
|
||
|
||
Scenario: The type gallery gains Relation
|
||
Given the inspector of a field that can still change type
|
||
Then the tile "Relation" with the link icon is the last tile, selectable like the others
|
||
And on a saved field the other tiles are dimmed to 40 % and not selectable, as for any locked type
|
||
|
||
Scenario: Adding a relation field
|
||
Given Events and a second content type Venues exist
|
||
When the user activates "Add field", types the label "Venue" and chooses the tile "Relation"
|
||
Then the new row shows the badge "New", the key "venue" (Auto, editable) and the chip reads "Unsaved changes"
|
||
And the inspector reads "Field: Venue" and shows, after the Type tiles and in this order, the select "Related content type" with the help "Choose the content type this field points to." and the group "How are they connected?"
|
||
And no related type is chosen yet and "Save" cannot send a relation without one (proposed copy for the message: "Choose a related content type.")
|
||
|
||
Scenario: The related content type select
|
||
Given the user is editing Events
|
||
Then the select lists the other content types by name, and Events itself, each with its key as a hint (proposed)
|
||
And choosing Venues sets the sentences of the preview to "Venue" and "Events"
|
||
|
||
Scenario: Three shape cards (design "Relation in the builder")
|
||
Given the related type is Venues, the owner Events
|
||
Then three cards are shown in a row (one column on mobile), each with a diagram of squares and a line, a title and a sentence
|
||
| card | sentence |
|
||
| One to one | Each Event has one Venue, and each Venue has one Event. |
|
||
| One to many | Each Event has one Venue. A Venue has many Events. |
|
||
| Many to many | Each Event has many Venues, and each Venue has many Events. |
|
||
And the selected card has the primary border and the sand fill with --rc-on-sand text, the others the accented border
|
||
And the cards are a radio group, one is selected at a time, and a new relation starts with "One to many" selected (proposed default)
|
||
|
||
Scenario: The preview of both sides
|
||
Given "One to many" is selected with Event and Venue
|
||
Then a block with the elevated background reads "Each Event has [one] Venue" and "Each Venue has [many] Events" followed by the muted text `· shown on Venue as the field "Events"`
|
||
And the bracketed words are drawn as selects showing the chosen kind and follow the selected card
|
||
And below it two cards "Event" (Venue: chip "Rue Verte hall") and "Venue" (Events: chips "Open day", "Lime plaster workshop" and "+1") illustrate both sides, side by side on desktop and stacked on mobile
|
||
And the inverse field is not stored anywhere and nothing about it is sent
|
||
|
||
Scenario Outline: The preview follows the kind
|
||
Given the owner Event and the related type Venue
|
||
When the user selects <card>
|
||
Then the sentences read <owner side> and <inverse side>
|
||
|
||
Examples:
|
||
| card | owner side | inverse side |
|
||
| One to one | Each Event has one Venue | Each Venue has one Event |
|
||
| One to many | Each Event has one Venue | Each Venue has many Events |
|
||
| Many to many | Each Event has many Venues| Each Venue has many Events |
|
||
|
||
Scenario: Plural and names come from the types
|
||
Given the owner "Events" and the related type "Venues"
|
||
Then "Event"/"Events" and "Venue"/"Venues" in the sentences are the singular and plural forms drawn from the type names (proposed rule: singular is the name without a final "s"; the field label is used for the singular of the related side)
|
||
|
||
Scenario: The PUT and POST body of a relation field
|
||
Given a relation "Venue" to "venues", kind "One to many", Required off
|
||
When the type is saved
|
||
Then the field is sent as {"key":"venue","label":"Venue","type":"relation","required":false,"unique":false,"default":null,"min":null,"max":null,"options":[],"relation":{"content_type":"venues","kind":"one_to_many"}}
|
||
And every other field is sent with "relation": null
|
||
And a round trip of the loaded field to the payload produces no change
|
||
|
||
Scenario: The inspector of a saved relation (locked) (state not drawn; proposed copy)
|
||
Given Events with a saved relation "Venue" as its field
|
||
When the user selects its row
|
||
Then the Label input and the Required switch are editable
|
||
And the Key is locked with "Used in the API, so it can't change."
|
||
And the legend of the tiles reads "Type" with a lock and "Can't change after saving", Relation is selected and the other tiles are dimmed to 40 %
|
||
And "Related content type" and the three cards are shown disabled with the selected card kept, under the note "The related type and the connection can't change after saving. (proposed copy)"
|
||
|
||
Scenario: A required relation added to a type with entries is refused first
|
||
Given Events with 12 entries and a new relation "Venue" that is required
|
||
When the user activates Save
|
||
Then no PUT is sent and the dialog "Review changes to Events" opens
|
||
And the row "Add Venue" has the warning badge "Can't be applied" and the text "Relation, required. A relation can't have a default, so the 12 existing entries would have no Venue. Make Venue optional to add it. (proposed copy)"
|
||
And "Back to editing" is enabled and "Apply changes" is disabled, so pressing it sends nothing
|
||
And an optional relation shows the clean row "Relation, optional. 12 entries are unaffected. (proposed copy)" with "Apply changes" enabled
|
||
|
||
Scenario: The server answers on the builder
|
||
Given the user applies a type with a relation
|
||
When the server answers <status> "<body>"
|
||
Then <result>
|
||
|
||
Examples:
|
||
| status | body | result |
|
||
| 409 | schema_incompatible | the existing "Nothing was changed" refusal flow runs unchanged |
|
||
| 422 | invalid_field | the existing invalid-field failure of the builder is shown, the edits stay |
|
||
|
||
Scenario: Deleting a type that another type points at (proposed copy)
|
||
Given Venues is the target of the relation on Events and Venues has no entries
|
||
When the user confirms the delete of Venues
|
||
Then the API answers 409 "content_type_referenced"
|
||
And the existing delete failure surface shows the title "You can't delete Venues yet" and the text "Events has a field that points to Venues. Remove that field first. (proposed copy)"
|
||
And the type is still listed
|
||
|
||
Scenario: The builder is for owners and admins
|
||
Given a verified user who is neither owner nor admin
|
||
Then the builder keeps its existing "You don't have access to Schema" state and no relation control is rendered
|
||
|
||
|
||
Scenario: Where the controls sit
|
||
Given Events with the fields Title, Venue and Speakers
|
||
Then the controls follow the schema order, full width in the 760 px column, each with its label ("Venue", "Speakers") in weight 500
|
||
|
||
Scenario: Single relation, empty, focused search (design "Venue")
|
||
Given /content/events/42 whose venue is null
|
||
When the user focuses the "Venue" control and types "rue"
|
||
Then the input shows the search icon, the typed text and "Search to add"
|
||
And a popover list under the input shows rows of at least 40 px (48 on mobile) with the title in weight 500 and a second muted 12/18 line, each row a button
|
||
| title | second line |
|
||
| Rue Verte hall | Lyon · 120 seats |
|
||
| Rue Verte yard | Lyon · outdoor |
|
||
| Rue du Port studio | Nantes · 24 seats|
|
||
And the first row has the elevated background
|
||
And the title is the first field of the related type and the second line is the next fields formatted as the entry list does, joined by " · " (up to 2 fields, proposed rule)
|
||
|
||
Scenario: Picking a single target
|
||
When the user activates the row "Rue Verte hall"
|
||
Then the list closes, the control shows one chip "Rue Verte hall" with an x, the value becomes 3, and the chip of the page reads "Unsaved changes"
|
||
And the live region says "Venue set to Rue Verte hall. (proposed copy)" and the focus stays in the control
|
||
And the chip's x removes it, sets the value to null, and says "Venue cleared. (proposed copy)"
|
||
|
||
Scenario: Many relation (design "Speakers")
|
||
Given Events with speakers [11, 12]
|
||
Then the control is a bordered box (1 px accented, min height 40, 44 on mobile) holding the chips "Ines Duarte" and "Jonas Pereira", each 24 px high (32 on mobile) with an x, then the muted text "Search to add"
|
||
And the help line under it reads "An event can have many speakers."
|
||
And a chip is a sand chip with --rc-on-sand text, weight 500
|
||
|
||
Scenario: Adding and removing in a many relation
|
||
When the user types "ines" in the control and activates the row "Ines Duarte"
|
||
Then the chip is added at the end in the chosen order and the row no longer appears in later results
|
||
When the user activates the x of a chip, or presses Backspace in the empty input
|
||
Then that chip is removed and the live region says "Ines Duarte removed from Speakers. (proposed copy)"
|
||
And a 51st target cannot be added: the search shows "You can add up to 50. (proposed copy)" and no row is selectable
|
||
|
||
Scenario: One to one targets already used (proposed copy)
|
||
Given a one_to_one relation
|
||
When the server answers 409 "invalid_entry: room: unique"
|
||
Then "That Venue is already used by another entry." shows under the control with a circle-alert icon, the error border applies, the chip reads "Not saved" and the focus moves to the control
|
||
|
||
Scenario: The search request
|
||
When the user types "rue"
|
||
Then the input is trimmed and debounced, and only GET /v1/content-type/venues/entry?q=rue&page=1&size=<page size> is sent
|
||
And the response replaces the list and a stale answer never overwrites a newer one
|
||
And with an empty input the first page of the type is listed without q (proposed)
|
||
|
||
Scenario: Picker loading (not drawn; proposed copy)
|
||
Given the search has not answered
|
||
Then after 150 ms the list shows three skeleton rows of the same height, with "Searching… (proposed copy)" announced politely, and no row is selectable
|
||
|
||
Scenario: No match (not drawn; proposed copy)
|
||
Given the search answers total 0 for "zzz"
|
||
Then the list shows `No Venues match "zzz"` (proposed copy) and the action "Clear search" that empties the input
|
||
And when the related type has no entries at all the list reads "No Venues yet. Add one in Content, then come back. (proposed copy)"
|
||
|
||
Scenario Outline: Search failures (not drawn; proposed copy)
|
||
Given the search answers <outcome>
|
||
Then <result>
|
||
|
||
Examples:
|
||
| outcome | result |
|
||
| 500 | the list shows "Couldn't load Venues" / "Check your connection and try again." with "Try again" (proposed copy) |
|
||
| a network error | the same |
|
||
| 429 | the existing rate-limit countdown alert |
|
||
| 401 | the global redirect, no local copy |
|
||
| 404 | "Couldn't load Venues" as for 500, since the related type may have been deleted (proposed copy) |
|
||
|
||
Scenario: Load more in the list
|
||
Given the search has more results than one page
|
||
When the user scrolls to the end of the list or activates "Load more" (proposed copy)
|
||
Then the next page is appended and the focus stays on the active row
|
||
|
||
Scenario: Keyboard
|
||
Given the control is focused and the list is open
|
||
Then ArrowDown and ArrowUp move the active row, Enter picks it, Esc closes the list and keeps the typed text, Tab leaves the control
|
||
And in a many relation Backspace on an empty input removes the last chip and chips are reachable with ArrowLeft
|
||
|
||
Scenario: A missing target (not drawn; proposed copy)
|
||
Given relations.venue is {"id":3,"missing":true}
|
||
Then the control shows a dashed chip "Entry no longer exists (proposed copy)" with an x and the help "You can still save. The entry keeps the link until you change it. (proposed copy)" when the field is optional
|
||
And when the field is required the help reads "Venue is required. Choose another to save. (proposed copy)", and saving with the missing target sends no request, shows the required message and focuses the control
|
||
And saving an optional field with the missing target sends the same id
|
||
|
||
Scenario: Required and empty
|
||
Given a required relation with no target
|
||
When the user activates "Save"
|
||
Then no request is sent, the error "Choose a Venue." replaces the help line, the border is --ui-error, the chip reads "Not saved" and the focus goes to the control
|
||
And only the first field in schema order with an error takes the focus
|
||
When the user picks a target
|
||
Then the error goes and the chip reads "Unsaved changes"
|
||
|
||
Scenario: Saving
|
||
Given the PUT has not answered
|
||
Then the form is dimmed to 70 %, the chips and the input are not operable and the list cannot open
|
||
And Save reads "Saving…"
|
||
|
||
Scenario Outline: What the control shows and sends
|
||
Given the loaded value <loaded> with relations <relations>
|
||
When the user <action> and activates "Save"
|
||
Then the control shows <shown>
|
||
And the data holds <sent>
|
||
|
||
Examples:
|
||
| loaded | relations | action | shown | sent |
|
||
| null | none | saves with nothing changed | no chip | "venue": null |
|
||
| 3 | existing venue 3 | saves with nothing changed | the chip "Rue Verte hall"| "venue": 3 |
|
||
| 3 | missing venue 3 | saves with nothing changed | the missing chip | "venue": 3 |
|
||
| 3 | existing venue 3 | picks "Rue Verte yard" | the chip "Rue Verte yard"| "venue": 4 |
|
||
| 3 | existing venue 3 | activates the x | no chip | "venue": null |
|
||
| [11] | speaker 11 | adds Jonas Pereira | two chips | "speakers": [11, 12] |
|
||
| [11,12] | speakers 11, 12 | removes both | no chip | "speakers": [] |
|
||
|
||
Scenario: Create payload with relations
|
||
Given /content/events/new with Title "Open day", Venue left empty and Speakers left empty
|
||
When the user activates "Save"
|
||
Then POST /v1/content-type/events/entry is sent once with "venue": null and "speakers": [] among the other keys, never absent
|
||
And with Rue Verte hall picked it holds "venue": 3, a JSON number
|
||
|
||
Scenario: Right after a pick the control uses the picked row
|
||
Given the user picks "Rue Verte yard" from the results
|
||
Then before any save the chip shows its title from the result and no extra request is made
|
||
When the PUT answers 200 with relations.venue
|
||
Then the control is rebuilt from the answered relations object
|
||
|
||
Scenario: Dirty tracking of a relation value
|
||
Given an entry whose venue is 3
|
||
When the user picks 4 then picks 3 again
|
||
Then the chip reads "Saved", "Save" is disabled and the leave guard does not prompt
|
||
And for a many relation the same ids in another order count as changed
|
||
|
||
Scenario Outline: Server answers for a relation field
|
||
Given the client found no error and the PUT or POST is sent
|
||
When the server answers <status> "<body>"
|
||
Then <result>
|
||
|
||
Examples:
|
||
| status | body | result |
|
||
| 422 | invalid_entry: venue: required | "Choose a Venue." under the control, error border, chip "Not saved", focus on the control |
|
||
| 422 | invalid_entry: venue: type | "Venue must point to an existing entry." (proposed copy) under the control, chip "Not saved" |
|
||
| 409 | invalid_entry: venue: unique | the one to one copy above |
|
||
| 422 | invalid_entry: venue: unknown_field | the read-only reload alert of features/content_entry_editor.feature, same as for media |
|
||
|
||
Scenario: Layout of the generated form with a relation
|
||
Given a boolean, a number or a date field before the relation
|
||
Then the relation never pairs and is full width at every viewport
|
||
|
||
Scenario: Mobile form
|
||
Given a 390 px viewport
|
||
Then chips are 32 px high, rows are 48 px high, the input is 44 px high, and the list opens as a bottom sheet (proposed) or under the input as drawn
|
||
|
||
|
||
Scenario Outline: The relation cell (design "Relation in the list")
|
||
Given the entry list of Events with the columns Title, Starts on, Venue and Speakers
|
||
And the item's relations <relations>
|
||
Then the cell shows <cell>
|
||
|
||
Examples:
|
||
| relations | cell |
|
||
| venue 3 "Rue Verte hall" | one chip "Rue Verte hall" (20 px high, 6 px padding) |
|
||
| speakers 11, 12 | two chips "Ines Duarte" and "Jonas Pereira" |
|
||
| speakers 11, 12 and two more | two chips and the muted 12 px text "+2" |
|
||
| no venue | the muted "–" |
|
||
| an empty speakers list | the muted "–" |
|
||
| a missing venue | a dashed chip "Entry no longer exists" (proposed copy) |
|
||
|
||
Scenario: Columns of the board (desktop)
|
||
Given the items 42, 41 and 40
|
||
Then the grid reads ID, Title, Starts on, Venue, Speakers with the columns 48 px, 1.4fr, 110 px, 200 px, 200 px
|
||
And row 42 shows "Rue Verte hall" and "Ines Duarte", "Jonas Pereira", "+2"; row 41 shows "–" and "Jonas Pereira"; row 40 shows "Rue Verte yard" and "–"
|
||
And rows stay 48 px high and the header is 36 px high on the elevated background
|
||
And the note "One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash." is a design annotation and is not rendered
|
||
|
||
Scenario: The mobile card
|
||
Given a 390 px viewport and Venue and Speakers among the columns
|
||
Then each card lists the title, then a line "Venue" (12 px muted) followed by its chips, then a line "Speakers" followed by its chips (wrapping), and "–" for none
|
||
And the card keeps its minimum height
|
||
|
||
Scenario: Columns menu
|
||
When the user opens "Columns"
|
||
Then "Venue" and "Speakers" are listed with the link icon, their keys, and a checkbox like other fields
|
||
And the cap of 6, Reset and the stored choice work as for any field
|
||
And a relation column is not sortable and has no sort icon and no aria-sort
|
||
|
||
Scenario: Cell data comes from the item
|
||
Then the chips are read from each item's own "relations" object with no extra request
|
||
And the plain text of a relation cell (card title, page title) reads the labels joined by ", " or "–", never the numeric id
|
||
|
||
Scenario: Relation as the first column
|
||
Given a type whose first field is a relation
|
||
Then the first column still links to the editor and the chips are the link content
|
||
And the page title and breadcrumb of such an entry read "Entry 42"
|
||
|
||
|
||
Scenario: Accessibility of the control
|
||
Then the control is a combobox with role="combobox", aria-expanded, aria-controls and aria-activedescendant on the input, labelled by the field label
|
||
And the list is role="listbox" with role="option" rows
|
||
And chips are a group labelled by the field label, each x is a button named "Remove Ines Duarte from Speakers"
|
||
And required adds aria-required, the error text has an id and aria-invalid and aria-describedby point at it then at the help
|
||
And one polite live region announces added, removed and cleared values and the result count ("3 results" / "No results", proposed copy)
|
||
And mobile targets are 44 px
|
||
|
||
Scenario: Accessibility of the builder
|
||
Then the shape cards are a radio group with a visible selected state that is not only a colour, the disabled locked controls are announced as disabled, and "Can't be applied" is a text badge
|