feat: relation field and picker

This commit is contained in:
Florian Sylvain committed 2026-10-23 00:13:42 +02:00
1 parent b45c99be3b
commit 37a77c9c22
92 files changed
+6826 -80

No files matched your search

@@ -92,6 +92,8 @@ func writeContentTypeError(w http.ResponseWriter, err error) {
http.Error(w, apperr.ContentTypeKeyTakenErrorMessage, http.StatusConflict) http.Error(w, apperr.ContentTypeKeyTakenErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrHasEntries): case errors.Is(err, domain.ErrHasEntries):
http.Error(w, apperr.ContentTypeHasEntriesErrorMessage, http.StatusConflict) http.Error(w, apperr.ContentTypeHasEntriesErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrReferenced):
http.Error(w, apperr.ContentTypeReferencedErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrSchemaIncompatible): case errors.Is(err, domain.ErrSchemaIncompatible):
http.Error(w, apperr.SchemaIncompatibleErrorMessage, http.StatusConflict) http.Error(w, apperr.SchemaIncompatibleErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrInvalidName): case errors.Is(err, domain.ErrInvalidName):
+45 -1
View File
@@ -43,6 +43,49 @@ type entryResponse struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Media map[string]any `json:"media"` Media map[string]any `json:"media"`
Relations map[string]any `json:"relations"`
}
type firstFieldBody struct {
Key string `json:"key"`
Type contenttype.FieldType `json:"type"`
Value any `json:"value"`
}
type relationBody struct {
ID uint32 `json:"id"`
ContentType string `json:"content_type"`
FirstField firstFieldBody `json:"first_field"`
}
type missingRelationBody struct {
ID uint32 `json:"id"`
Missing bool `json:"missing"`
}
func toRelationItem(item useCases.EntryRelation) any {
if item.Missing {
return missingRelationBody{ID: item.ID, Missing: true}
}
return relationBody{ID: item.ID, ContentType: item.ContentType, FirstField: firstFieldBody{Key: item.FirstField.Key, Type: item.FirstField.Type, Value: item.FirstField.Value}}
}
func toRelationsResponse(relations map[string]useCases.EntryRelations) map[string]any {
body := make(map[string]any, len(relations))
for key, held := range relations {
if !held.Many {
if len(held.Items) > 0 {
body[key] = toRelationItem(held.Items[0])
}
continue
}
items := make([]any, 0, len(held.Items))
for _, item := range held.Items {
items = append(items, toRelationItem(item))
}
body[key] = items
}
return body
} }
type mediaBody struct { type mediaBody struct {
@@ -81,6 +124,7 @@ func toResponse(typed useCases.TypedEntry) entryResponse {
CreatedAt: typed.CreatedAt, CreatedAt: typed.CreatedAt,
UpdatedAt: typed.UpdatedAt, UpdatedAt: typed.UpdatedAt,
Media: toMediaResponse(typed.Media), Media: toMediaResponse(typed.Media),
Relations: toRelationsResponse(typed.Relations),
} }
} }
@@ -181,7 +225,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return return
} }
page, err := h.ListUseCase.ListPage(useCases.ListEntriesCommand{ContentTypeKey: chi.URLParam(r, "key"), Page: query.Page, Size: query.Size, Sort: query.Sort}) page, err := h.ListUseCase.ListPage(useCases.ListEntriesCommand{ContentTypeKey: chi.URLParam(r, "key"), Page: query.Page, Size: query.Size, Sort: query.Sort, Query: r.URL.Query().Get("q")})
if err != nil { if err != nil {
writeEntryError(w, err) writeEntryError(w, err)
return return
+1
View File
@@ -92,6 +92,7 @@ const SlugSuggestionHeader = "X-Slug-Suggestion"
const ContentTypeNotFoundErrorMessage = "content_type_not_found" const ContentTypeNotFoundErrorMessage = "content_type_not_found"
const ContentTypeKeyTakenErrorMessage = "content_type_key_taken" const ContentTypeKeyTakenErrorMessage = "content_type_key_taken"
const ContentTypeHasEntriesErrorMessage = "content_type_has_entries" const ContentTypeHasEntriesErrorMessage = "content_type_has_entries"
const ContentTypeReferencedErrorMessage = "content_type_referenced"
const InvalidContentTypeKeyErrorMessage = "invalid_content_type_key" const InvalidContentTypeKeyErrorMessage = "invalid_content_type_key"
const InvalidIconErrorMessage = "invalid_icon" const InvalidIconErrorMessage = "invalid_icon"
const BuiltinTypeNotFoundErrorMessage = "builtin_type_not_found" const BuiltinTypeNotFoundErrorMessage = "builtin_type_not_found"
+380
View File
@@ -0,0 +1,380 @@
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
+59
View File
@@ -0,0 +1,59 @@
# QA procedure: relation field in the SPA
Two parts. Part 1 is automated (Vitest component tests, `task test:web`). Part 2 is the visual gate (`task visual:check`). Part 3 is manual. The Go side is in `relation_field_type.qa.md`. Until `task test:web` exists, SPA tests are reported NOT RUN, never passing.
## Part 1: automated (Vitest + Vue Test Utils)
Mock `/v1` per test with the shapes below; no live server. A field is `{"key","label","type":"relation","required","unique":false,"default":null,"min":null,"max":null,"options":[],"relation":{"content_type","kind"}}` and every other field carries `"relation": null`.
Registry
1. `lib/fieldTypes`: the relation entry has the properties of the registry outline (label, icon `lucide:link`, control `RelationField`, no unique/limits/default/choices, empty value per kind, sample null or []). An unknown type still falls back to text; a relation never does.
2. Client validation table (single and many, required/optional, 0, "3", [3], duplicates, 51 ids) and the message map including the "not produced" rules.
Builder (one test per state)
3. Type gallery: Relation is the last tile; locked on a saved field with the other tiles dimmed.
4. New relation: the related-type select lists the other types (mock `GET /v1/content-type`), the help text, the three cards with the exact sentences, default "One to many" (proposed); choosing a type updates the sentences.
5. Preview outline: the kind change updates both sentences and the line `· shown on Venue as the field "Events"`; the example cards "Event" and "Venue" show the board's chips (Rue Verte hall, Open day, Lime plaster workshop, +1). Nothing about the inverse is in any request.
6. Save body: relation sent as `{"content_type","kind"}`, others `"relation": null`; a loaded field round-trips unchanged; no related type chosen blocks Save with the proposed message.
7. Saved relation: key, tiles, related type and cards are disabled; Label and Required editable; the proposed lock note shows.
8. Review: required relation + entries > 0 shows the flagged row and disabled "Apply changes" with no PUT; optional shows the clean row; required with 0 entries sends at once; making an optional relation required shows "Entries may not fit" with Apply enabled.
9. Server answers: 409 `schema_incompatible` and 422 `invalid_field` map to the existing flows.
10. Delete refused: 409 `content_type_referenced` shows the proposed copy and the type stays listed.
11. A non-owner/admin sees no builder.
Entry form (one test per state)
12. Single control: type "rue" -> only `GET /v1/content-type/venues/entry?q=rue&page=1&size=...` is sent (debounced, trimmed, stale answers ignored); rows show title and second line; pick sets the value to 3, one chip, "Unsaved changes", live message.
13. Many control: chips for [11, 12], "Search to add", the help line; add keeps order; x and Backspace remove; picked ids are not offered again; the 51st is blocked with the proposed message.
14. States: loading (skeleton after 150 ms), no match with "Clear search", empty type, 500, network error, 429 countdown, 401 redirect, 404 (all with the proposed copy where tagged), "Try again" re-sends only the search.
15. Missing target (optional and required), saving, keyboard (arrows, Enter, Esc, Tab, Backspace), required-empty error and focus order (first invalid field only).
16. Outline "What the control shows and sends" (all 7 rows) and the create payload with `venue: null`, `speakers: []` and with a picked number.
17. Dirty tracking: back to the original value gives "Saved"; reordered many ids count as changed.
18. Server answers table: `required`, `type`, `unique`, `unknown_field` (reload alert), each with the right copy, chip and focus.
19. Layout: a relation after boolean/number/date never pairs.
List
20. Cell outline (one chip, two chips, two chips + "+2", dash for null, dash for [], missing chip) from each item's `relations` with no extra request; relation columns are not sortable (no sort icon, no `aria-sort`).
21. Desktop grid for items 42/41/40 matches the board texts; the mobile card shows the labelled lines; the Columns menu lists Venue and Speakers with the link icon and the cap of 6.
22. Plain text of a relation cell never shows the numeric id; a first-field relation keeps the link and the "Entry N" title.
Accessibility
23. Combobox/listbox roles and attributes, chip group label, "Remove X from Y" button names, required/invalid/describedby wiring, the live region texts.
## Part 2: visual (`task visual:check -- <screen>`)
Hard gate: each capture within 10 % at 1440x900 and 390x844, light and dark. Add setups in `web/visual/states.ts` with the board's own sample data; no clock-sensitive state exists in this group.
| Screen | State | Board sample data the mock must reuse |
| --- | --- | --- |
| schema | Relation in the builder | Events with Venue (label), key `venue`, related type Venue, "One to many" selected, preview sentences, example cards with chips "Rue Verte hall", "Open day", "Lime plaster workshop", "+1" |
| entry (content/events/42) | Relation in the entry form | Title "Lime plaster workshop" (21 / 80); Venue search "rue" with rows "Rue Verte hall / Lyon · 120 seats", "Rue Verte yard / Lyon · outdoor", "Rue du Port studio / Nantes · 24 seats"; Speakers chips "Ines Duarte", "Jonas Pereira"; help "An event can have many speakers." |
| content (events) | Relation in the list | Items 42 "Lime plaster workshop" 21 Nov 2026 (Rue Verte hall; Ines Duarte, Jonas Pereira, +2), 41 "Heat pumps: what the numbers say" 3 Nov 2026 (–; Jonas Pereira), 40 "Open day at the Rue Verte site" 12 Nov 2026 (Rue Verte yard; –) |
Notes:
- The board's states are drawn with a "Later" badge; the SPA does not render it. Expect a pixel difference at the badge. If the 10 % gate fails because of it, the user decides on a documented `exception` (`approvedBy`); the QA never adds one.
- The board's builder shows a four-tile gallery plus Relation; the SPA shows the full gallery. The mismatch is reported to the user, not worked around.
- States tagged "(proposed copy)" (loading, no match, error, missing chip, locked inspector, review refusal, delete refusal) are not on the board and get Vitest tests only, no visual capture.
## Part 3: manual (screenshots can't cover)
- Keyboard and focus order across the combobox, chips and list; screen reader announcements of the live region.
- Hover and motion of the popover and of the chips' x; the mobile bottom-sheet versus under-input choice.
- Real SQLite end to end with the built binary: build the type pair, pick, save, reload, delete a target and see the missing chip.
- `bunx playwright install chromium` the first time on a machine.
+235
View File
@@ -0,0 +1,235 @@
Feature: Relation field type for custom content types
As a site owner or admin
I want a content type field that links an entry to entries of another content type
So that structured content can refer to other structured content
Background:
Given internal/domain/contenttype adds the field type "relation" next to text, richtext, number, boolean, date, select and media
And a relation field carries "relation": {"content_type": <key of an existing type>, "kind": "one_to_one" | "one_to_many" | "many_to_many"}
And "one_to_many" reads from the owner: each owner entry has one target, a target has many owner entries
And every field response gains the key "relation", null for the other types, so a field has exactly ten keys
And a relation field accepts only label, key, required and relation; unique, default, min, max and options are refused with ErrInvalidField
And the target type must exist, else ErrInvalidField; a self relation is allowed for one_to_many and many_to_many and refused for one_to_one
And the connection is stored once, on the owner field; the inverse side is derived, never stored, never added to the target schema, and the target type's schema, entry responses, list and routes are unchanged
And the owner value is an id (uint32, at least 1) or null for one_to_one and one_to_many, and an array of ids ([] when empty, at most 50, no duplicates, order kept) for many_to_many
And entry.Validate stays pure; the existence of target entries is checked by CreateEntry and UpdateEntry through entry.Repository
And a changed value means an id not equal to the one in the stored document for that key; on POST every id is changed; on PUT an unchanged id is not checked for existence unless the field is required
And a changed id must be an existing entry of the target type, else 422 "invalid_entry: <key>: type"
And a required relation is never saved empty: null, absent or [] give 422 "invalid_entry: <key>: required", and so does an unchanged id whose entry is gone
And a one_to_one id already held by another entry of the owner type is refused with 409 "invalid_entry: <key>: unique" (entry.ErrUniqueViolation)
And rules are checked in field order for Validate, then target existence in field order, then unique, and the body names one field per answer
And an entry response keeps its eight keys and adds "relations", always present ({} when no relation is set)
And "relations"[key] is {"id","content_type","first_field":{"key","type","value"}} for an existing target, or {"id","missing":true} when it is gone; for many_to_many it is an array of these in stored order
And first_field is the first field of the target type in schema order with its raw stored value, never formatted by the server
And the same "relations" object is on POST 201, PUT 200, GET one and every item of the list
And GET /v1/content-type/{key}/entry accepts ?q=, a trimmed case-insensitive substring of the display text of the first field, filtered in Go on the unpaged list before pagination, so total counts the matches; an empty q means no filter
And deleting an entry that other entries reference is allowed and leaves them untouched
And DELETE /v1/content-type/{key} on a type that is the target of a relation field is refused with 409 "content_type_referenced" (contenttype.ErrReferenced, new apperr string), even with no entries
And a saved relation field's type, target and kind never change: a change is refused with 422 "invalid_field"
And a new required relation on a type with entries gives 409 "schema_incompatible"
And no repository method is added; the code works on SQLite, MySQL/MariaDB and PostgreSQL with no JSON operators
And the key-set assertions of the earlier custom content type, entry and media features and tests are amended (field 9 to 10 keys, entry 8 to 9 keys)
And types: "venue" has title (text, required) and city (text); "event" has title (text, required) then venue (relation to venue, one_to_many, label "Venue") then speakers (relation to speaker, many_to_many); "speaker" has name (text, required)
And entries: venue 3 "Rue Verte hall", venue 4 "Rue Verte yard"; speaker 11 "Ines Duarte", speaker 12 "Jonas Pereira"
And users: admin id 2 (verified) can write types; any verified user can write entries
Scenario: Creating a content type with a relation field
Given a valid jwt cookie for admin 2 and the type "venue"
When the client sends POST /v1/content-type with fields title (text, required) and
{"key":"venue","label":"Venue","type":"relation","required":false,"unique":false,"default":null,"min":null,"max":null,"options":[],"relation":{"content_type":"venue","kind":"one_to_many"}}
Then the response status is 201
And the venue field comes back with that relation object and the other fields have "relation": null
Scenario Outline: A relation field with a bad definition is refused
Given a valid jwt cookie for admin 2
When the client sends POST /v1/content-type with a relation field that has <defect>
Then the response status is 422
And the response body is "invalid_field"
And no content type is stored
Examples:
| defect |
| relation null |
| content_type "nope" (unknown type) |
| content_type "" or missing |
| kind "many_to_one" |
| kind missing |
| a relation object on a text field |
| unique true |
| default 3 |
| min 1 |
| max 5 |
| options ["a"] |
| a one_to_one self relation |
Scenario: The shape of a saved relation never changes
Given the type "event" has a saved relation "venue" (one_to_many to venue) and at least one entry
When admin 2 sends PUT /v1/content-type/event changing its kind to many_to_many
Then the response is 422 invalid_field and the schema is unchanged
When admin 2 sends PUT changing its target to speaker
Then the response is 422 invalid_field and the schema is unchanged
When admin 2 sends PUT turning it into a text field
Then the response is 422 invalid_field and the schema is unchanged
Scenario Outline: Adding a relation field to a type that has entries
Given the type "event" has 12 entries
When admin 2 sends PUT adding a relation field "venue" that is <required>
Then the response status is <status> and the body is <message>
Examples:
| required | status | message |
| required | 409 | schema_incompatible |
| optional | 200 | |
Scenario: Deleting a type that another type points at
Given the type "venue" has no entries and "event" has a relation to it
When admin 2 sends DELETE /v1/content-type/venue
Then the response status is 409 with "content_type_referenced" and venue still exists
When the relation field is removed from "event" and DELETE is sent again
Then the response status is 204
Scenario: Creating an entry with a single target
When a verified user sends POST /v1/content-type/event/entry with {"data":{"title":"Lime plaster workshop","venue":3}}
Then the response status is 201 and data.venue is 3
And relations is {"venue":{"id":3,"content_type":"venue","first_field":{"key":"title","type":"text","value":"Rue Verte hall"}}}
Scenario: Creating an entry with many targets
When the client sends POST with {"data":{"title":"A","speakers":[12,11]}}
Then data.speakers is [12,11] and relations.speakers lists speaker 12 then speaker 11
Scenario Outline: Empty optional relations
When the client sends POST with <data>
Then the response status is 201, <stored>, and relations is {}
Examples:
| data | stored |
| {"data":{"title":"A","venue":null}} | data.venue is null |
| {"data":{"title":"A"}} | data.venue is absent |
| {"data":{"title":"A","speakers":[]}} | data.speakers is [] |
Scenario Outline: Values that break a relation are refused and store nothing
Given "venue" is <required> and "speakers" is many_to_many
When the client sends POST with the data <data>
Then the response status is <status> and the body is <message>
And no entry exists
Examples:
| required | data | status | message |
| required | {"title":"A"} | 422 | invalid_entry: venue: required |
| required | {"title":"A","venue":null} | 422 | invalid_entry: venue: required |
| optional | {"title":"A","venue":999} | 422 | invalid_entry: venue: type |
| optional | {"title":"A","venue":"3"} | 422 | invalid_entry: venue: type |
| optional | {"title":"A","venue":0} | 422 | invalid_entry: venue: type |
| optional | {"title":"A","venue":3.5} | 422 | invalid_entry: venue: type |
| optional | {"title":"A","venue":[3]} | 422 | invalid_entry: venue: type |
| optional | {"title":"A","speakers":11} | 422 | invalid_entry: speakers: type |
| optional | {"title":"A","speakers":[11,11]} | 422 | invalid_entry: speakers: type |
| optional | {"title":"A","speakers":[11,999]}| 422 | invalid_entry: speakers: type |
| optional | {"title":"A","speakers":[3]} | 422 | invalid_entry: speakers: type |
| optional | speakers with 51 ids | 422 | invalid_entry: speakers: type |
Scenario: A required many relation is never empty
Given "speakers" is required
When the client sends POST with speakers [] or with the key absent
Then the response is 422 invalid_entry: speakers: required
Scenario: One to one keeps targets exclusive
Given the type "talk" has a one_to_one relation "room" to "venue" and a talk already holds venue 3
When the client sends POST with room 3
Then the response is 409 "invalid_entry: room: unique" and nothing is stored
When the holder is updated with room 3 unchanged
Then the response is 200
When the holder is deleted and the POST is sent again
Then the response is 201
Scenario: Replacing and clearing targets
Given the entry 5 holds venue 3 and speakers [11]
When a verified user sends PUT with venue 4 and speakers [11,12]
Then data and relations reflect venue 4 and speakers 11, 12
When the user sends PUT with venue null and speakers []
Then relations is {}
Scenario: A target deleted after the entry was saved
Given the entry 5 holds venue 3 and speakers [11,12]
When entry 3 of venue and entry 12 of speaker are deleted (both answer 204)
And the client sends GET /v1/content-type/event/entry/5
Then data is unchanged
And relations.venue is {"id":3,"missing":true}
And relations.speakers is [{"id":11,"content_type":"speaker","first_field":{"key":"name","type":"text","value":"Ines Duarte"}},{"id":12,"missing":true}]
And the entry 5 was not modified (updated_at and updated_by unchanged)
Scenario Outline: Unchanged and changed ids when the target is gone
Given the entry 5 holds venue 3 and venue 3 was deleted
When the user sends PUT with venue <value>
Then the response status is <status> and the body is <message>
Examples:
| value | status | message |
| 3 | 200 | |
| 999 | 422 | invalid_entry: venue: type |
| 4 | 200 | |
| null | 200 | |
Scenario Outline: A required relation with a missing target can't be saved as is
Given venue is required, entry 5 holds venue 3 and venue 3 was deleted
When the user sends PUT with venue <value>
Then the status is <status>, the body is <message> and the stored entry is <outcome>
Examples:
| value | status | message | outcome |
| 3 | 422 | invalid_entry: venue: required | unchanged |
| null | 422 | invalid_entry: venue: required | unchanged |
| 4 | 200 | | updated |
Scenario: A create always treats the id as changed
Given venue 3 was deleted
When the client sends POST with venue 3
Then the response is 422 invalid_entry: venue: type
Scenario: Two broken fields name only one
Given a body with a required text left empty and a relation to a missing target
Then the answer names one field: Validate rules first in field order, then existence, then unique
Scenario: Entry list responses carry relations per item
Given 3 events: one with a venue and two speakers, one with a deleted venue, one with nothing
When the client sends GET /v1/content-type/event/entry
Then each item keeps its eight keys and adds "relations"
And items, total, page and size are unchanged
And a type without a relation field returns "relations": {} and makes no target repository call
Scenario Outline: Searching the entries of a type
Given the type "venue" has entries titled "Rue Verte hall", "Rue Verte yard", "Rue du Port studio"
When the client sends GET /v1/content-type/venue/entry?q=<q>
Then total is <total>
Examples:
| q | total |
| rue | 3 |
| RUE VERTE | 2 |
| yard | 1 |
| nothing | 0 |
| | 3 |
Scenario: Search is paginated after filtering
Given 30 venues match "rue"
When the client sends GET /v1/content-type/venue/entry?q=rue&page=2&size=10
Then total is 30, page is 2, size is 10 and items holds the second ten matches in the requested sort
Scenario: Deleting an entry that is referenced
Given 4 events hold venue 3
When a verified user sends DELETE /v1/content-type/venue/entry/3
Then the response status is 204 and the 4 events are not modified
Scenario: The target type, entries and API are unchanged
Given "event" has a one_to_many relation to "venue"
When the client sends GET /v1/content-type/venue and GET /v1/content-type/venue/entry/3
Then neither response carries the inverse field "Events" and the venue field list is the one saved
Scenario: Use case level
Given fake contenttype and entry repositories
Then contenttype.ValidateFields returns ErrInvalidField for each refused definition
And entry.Validate returns errors.Is(err, entry.ErrInvalidEntry) with Key and Rule "required" or "type" for the shapes above
And CreateEntry and UpdateEntry return the same for a missing target and perform no Create or Update
And UpdateContentType returns contenttype.ErrSchemaIncompatible for a new required relation and ErrInvalidField for a changed shape
And DeleteContentType returns contenttype.ErrReferenced for a target type
+48
View File
@@ -0,0 +1,48 @@
# QA procedure: relation field type (API only)
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP, no SPA.
## Setup shared by every case
1. Call `validator.InitValidator()` once per test.
2. Reuse `mockContentTypeRepo` and `mockEntryRepo` of the custom content type and media pairs. Fake names stay unique across the package. The entry fake needs `createCalls`, `updateCalls`, a `getCalls` counter per content type and a `deleteEntry(contentTypeID, id)` helper. No repository method is added.
3. Build `NewHandler(...)` for the content type and entry handlers over use cases wired to the fakes. Mount them on a fresh `chi.NewRouter()` mirroring api/router.go under `/v1/content-type`, with `jwtauth.Verifier`, `jwtauth.Authenticator` and a test `jwtauth.New("HS256", []byte("test-secret"), nil)`, and `RequireOwnerOrAdmin` on the type write routes. Prefer `api.InitBackendRoutes` with a partial `*api.UseCases` if it works.
4. Seed admin 2 and a plain verified user. Seed the types venue, speaker, event and talk, and the entries venue 3, 4 and speaker 11, 12 from the feature.
5. Inject a fake clock so `updated_at` > `created_at`. Decode bodies into `map[string]any` and compare numbers as `float64`.
## Schema
6. POST a valid relation field -> 201; `relation` is the object sent; every other field has `"relation": null`; every field has exactly ten keys.
7. Refusal table (null relation, unknown target, empty target, bad kind, missing kind, relation on a text field, unique/default/min/max/options, one_to_one self) -> 422 `invalid_field`, repo empty.
8. Locking: kind, target and type changes on a saved relation -> 422 `invalid_field`, schema byte-identical. A type with no entries keeps today's free type change for non-saved shapes.
9. Type with 12 entries: add required relation -> 409 `schema_incompatible`, unchanged; add optional -> 200 and the old entries read with `relations` `{}`.
10. DELETE a target type with no entries -> 409 `content_type_referenced` (plain text); after removing the relation field -> 204. A type with entries is still 409 `content_type_has_entries`.
## Entry writes
11. Single and many targets -> 201, `data` as sent (array order kept), `relations` as in the feature. The target object has exactly `id`, `content_type`, `first_field`; `first_field` has exactly `key`, `type`, `value`, with the raw stored value.
12. Empty optional (null, absent, []) -> 201, `relations` is `{}` (assert JSON `{}`, not null). A required many field with [] or absent -> 422 required.
13. Refusal table -> exact status and body, `createCalls == 0`. The 51-id case is built in the test.
14. A venue id in a speaker field -> 422 type.
15. One-to-one exclusivity: 409 `invalid_entry: room: unique`, nothing stored; PUT of the holder unchanged -> 200; after deleting the holder, POST -> 201.
16. PUT replace/clear -> 200 with updated `relations`.
17. Dangling targets: `deleteEntry` venue 3 and speaker 12 -> GET entry 5 shows `{"id":3,"missing":true}` and the mixed array; `updated_at`/`updated_by` unchanged. PUT unchanged id -> 200; 999 -> 422 type; other id or null -> 200.
18. Required relation with a gone target: PUT same id -> 422 required; null -> 422 required; other id -> 200. POST with a deleted id -> 422 type.
19. Ordering: two bad fields name one only (Validate in field order, then existence, then unique).
## Reads
20. List with 3 events (full, dangling, empty) -> every item has the 8 old keys plus `relations`; `total`, `page`, `size` unchanged; `items` is `[]` for an empty type.
21. A type without a relation field -> `relations` `{}` and zero Get calls on target types. 100 entries pointing at one target call Get at most once for that id.
22. `?q=` table (case-insensitive, trimmed, empty = all, none = 0); `total` counts matches; page 2 size 10 of 30 matches returns the second ten; invalid `page`/`size`/`sort` keep their existing errors.
23. GET venue and GET a venue entry after the relation exists on event -> no inverse field, same keys as before.
## Delete
24. DELETE a referenced venue entry -> 204; the referencing events are byte-identical and no Update is recorded.
## Use case level (no HTTP)
25. `ValidateFields`, `entry.Validate`, `CreateEntry`/`UpdateEntry`, `UpdateContentType`, `DeleteContentType` as in the last scenario (`errors.Is`).
## Existing tests to amend
26. Key-set assertions in the custom content type, entry and media specs and tests: field 9 -> 10 keys, entry 8 -> 9 keys. The coder owns this.
## Manual verification (not exercised by the Go tests)
- Real SQLite, then MySQL/MariaDB and PostgreSQL via the driver tasks: create venue and event with a relation, save entries, delete the venue entry, reload the event and confirm the dangling id and `missing`.
- Time `GET .../entry?q=` against a type with 1,000 entries (the filter loads the whole type).
- The SPA is the second pair and is not checked here.
@@ -13,6 +13,7 @@ var (
ErrInvalidIcon = errors.New("invalid content type icon") ErrInvalidIcon = errors.New("invalid content type icon")
ErrInvalidField = errors.New("invalid field") ErrInvalidField = errors.New("invalid field")
ErrHasEntries = errors.New("the content type still has entries") ErrHasEntries = errors.New("the content type still has entries")
ErrReferenced = errors.New("the content type is the target of a relation field")
ErrSchemaIncompatible = errors.New("the schema is incompatible with the stored entries") ErrSchemaIncompatible = errors.New("the schema is incompatible with the stored entries")
) )
@@ -26,6 +27,7 @@ const (
TypeDate FieldType = "date" TypeDate FieldType = "date"
TypeSelect FieldType = "select" TypeSelect FieldType = "select"
TypeMedia FieldType = "media" TypeMedia FieldType = "media"
TypeRelation FieldType = "relation"
) )
const ( const (
@@ -54,6 +56,7 @@ type Field struct {
Min any `json:"min"` Min any `json:"min"`
Max any `json:"max"` Max any `json:"max"`
Options []string `json:"options"` Options []string `json:"options"`
Relation *Relation `json:"relation"`
} }
type ContentType struct { type ContentType struct {
@@ -21,6 +21,7 @@ var kinds = map[FieldType]kind{
TypeDate: {accepts: isDate, measure: dateDays, bound: dateDays, unique: true}, TypeDate: {accepts: isDate, measure: dateDays, bound: dateDays, unique: true},
TypeSelect: {accepts: isString, unique: true}, TypeSelect: {accepts: isString, unique: true},
TypeMedia: {accepts: isMediaID}, TypeMedia: {accepts: isMediaID},
TypeRelation: {},
} }
func MediaID(value any) (uint32, bool) { func MediaID(value any) (uint32, bool) {
+2 -2
View File
@@ -63,7 +63,7 @@ func (f Field) validSchema() bool {
if f.Unique && !fieldKind.unique { if f.Unique && !fieldKind.unique {
return false return false
} }
return f.validOptions() && f.validBounds(fieldKind) && f.validDefault() return f.validRelation() && f.validOptions() && f.validBounds(fieldKind) && f.validDefault()
} }
func (f Field) validOptions() bool { func (f Field) validOptions() bool {
@@ -109,7 +109,7 @@ func (f Field) validDefault() bool {
if f.Default == nil { if f.Default == nil {
return true return true
} }
if f.Type == TypeMedia { if f.Type == TypeMedia || f.Type == TypeRelation {
return false return false
} }
return !f.Unique && f.Violation(f.Default) == "" return !f.Unique && f.Violation(f.Default) == ""
@@ -3,6 +3,9 @@ package contenttype
import "slices" import "slices"
func (f Field) Violation(value any) string { func (f Field) Violation(value any) string {
if f.Type == TypeRelation {
return f.relationViolation(value)
}
fieldKind, known := kinds[f.Type] fieldKind, known := kinds[f.Type]
if !known || !fieldKind.accepts(value) { if !known || !fieldKind.accepts(value) {
return RuleType return RuleType
@@ -0,0 +1,134 @@
package contenttype
const MaxRelationIDs = 50
type RelationKind string
const (
RelationOneToOne RelationKind = "one_to_one"
RelationOneToMany RelationKind = "one_to_many"
RelationManyToMany RelationKind = "many_to_many"
)
type Relation struct {
ContentType string `json:"content_type"`
Kind RelationKind `json:"kind"`
}
func (r Relation) validKind() bool {
return r.Kind == RelationOneToOne || r.Kind == RelationOneToMany || r.Kind == RelationManyToMany
}
func (f Field) validRelation() bool {
if f.Type != TypeRelation {
return f.Relation == nil
}
return f.Relation != nil && f.Relation.ContentType != "" && f.Relation.validKind()
}
func (f Field) IsRelation() bool {
return f.Type == TypeRelation && f.Relation != nil
}
func (f Field) HoldsManyRelations() bool {
return f.IsRelation() && f.Relation.Kind == RelationManyToMany
}
func (f Field) IsExclusiveRelation() bool {
return f.IsRelation() && f.Relation.Kind == RelationOneToOne
}
func (f Field) RelationIDs(value any) ([]uint32, bool) {
if value == nil {
return []uint32{}, true
}
if !f.HoldsManyRelations() {
id, ok := MediaID(value)
if !ok {
return nil, false
}
return []uint32{id}, true
}
return manyRelationIDs(value)
}
func manyRelationIDs(value any) ([]uint32, bool) {
var items []any
switch typed := value.(type) {
case []any:
items = typed
case []uint32:
for _, id := range typed {
items = append(items, id)
}
default:
return nil, false
}
if len(items) > MaxRelationIDs {
return nil, false
}
ids := make([]uint32, 0, len(items))
seen := make(map[uint32]bool, len(items))
for _, item := range items {
id, ok := MediaID(item)
if !ok || seen[id] {
return nil, false
}
seen[id] = true
ids = append(ids, id)
}
return ids, true
}
func (f Field) relationViolation(value any) string {
if _, ok := f.RelationIDs(value); !ok {
return RuleType
}
return ""
}
func EmptyRelation(field Field, value any) bool {
if !field.HoldsManyRelations() {
return false
}
ids, ok := field.RelationIDs(value)
return ok && len(ids) == 0
}
type RelationRef struct {
Field Field
IDs []uint32
}
func RelationRefs(fields []Field, data map[string]any) []RelationRef {
var refs []RelationRef
for _, field := range RelationFields(fields) {
if ids, ok := field.RelationIDs(data[field.Key]); ok && len(ids) > 0 {
refs = append(refs, RelationRef{Field: field, IDs: ids})
}
}
return refs
}
func RelationFields(fields []Field) []Field {
var relations []Field
for _, field := range fields {
if field.IsRelation() {
relations = append(relations, field)
}
}
return relations
}
func ReferencesType(fields []Field, key string) bool {
for _, field := range RelationFields(fields) {
if field.Relation.ContentType == key {
return true
}
}
return false
}
func SameRelation(left Field, right Field) bool {
return left.IsRelation() && right.IsRelation() && *left.Relation == *right.Relation
}
+40 -2
View File
@@ -2,8 +2,11 @@ package entry
import ( import (
"RenewCMS/internal/domain/contenttype" "RenewCMS/internal/domain/contenttype"
"fmt"
"reflect" "reflect"
"sort" "sort"
"strconv"
"strings"
) )
func SameValue(left any, right any) bool { func SameValue(left any, right any) bool {
@@ -49,7 +52,7 @@ func Validate(fields []contenttype.Field, data map[string]any) error {
func validateField(field contenttype.Field, data map[string]any) error { func validateField(field contenttype.Field, data map[string]any) error {
value := data[field.Key] value := data[field.Key]
if value == nil { if value == nil || contenttype.EmptyRelation(field, value) {
if field.Required { if field.Required {
return InvalidField(field.Key, RuleRequired) return InvalidField(field.Key, RuleRequired)
} }
@@ -82,7 +85,7 @@ func firstUnknownField(fields []contenttype.Field, data map[string]any) error {
func UniqueValues(fields []contenttype.Field, data map[string]any) []UniqueValue { func UniqueValues(fields []contenttype.Field, data map[string]any) []UniqueValue {
var values []UniqueValue var values []UniqueValue
for _, field := range fields { for _, field := range fields {
if value := data[field.Key]; field.Unique && value != nil { if value := data[field.Key]; (field.Unique || field.IsExclusiveRelation()) && value != nil {
values = append(values, UniqueValue{Key: field.Key, Value: value}) values = append(values, UniqueValue{Key: field.Key, Value: value})
} }
} }
@@ -103,3 +106,38 @@ func MissingMedia(field contenttype.Field, unchanged bool) error {
} }
return InvalidField(field.Key, contenttype.RuleType) return InvalidField(field.Key, contenttype.RuleType)
} }
func RelationIDs(fields []contenttype.Field, data map[string]any) map[string]map[uint32]bool {
ids := map[string]map[uint32]bool{}
for _, ref := range contenttype.RelationRefs(fields, data) {
held := make(map[uint32]bool, len(ref.IDs))
for _, id := range ref.IDs {
held[id] = true
}
ids[ref.Field.Key] = held
}
return ids
}
func MissingRelation(field contenttype.Field, unchanged bool) error {
return MissingMedia(field, unchanged)
}
func MatchesQuery(fields []contenttype.Field, data map[string]any, query string) bool {
if len(fields) == 0 {
return false
}
return strings.Contains(strings.ToLower(displayText(data[fields[0].Key])), strings.ToLower(query))
}
func displayText(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return typed
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
}
return fmt.Sprint(value)
}
@@ -37,6 +37,9 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
if err != nil { if err != nil {
return contenttype.ContentType{}, err return contenttype.ContentType{}, err
} }
if err := ensureRelationTargets(u.contentTypes, command.Key, fields); err != nil {
return contenttype.ContentType{}, err
}
if err := u.ensureKeyFree(command.Key); err != nil { if err := u.ensureKeyFree(command.Key); err != nil {
return contenttype.ContentType{}, err return contenttype.ContentType{}, err
} }
@@ -36,6 +36,10 @@ func (u *CreateEntryUseCase) Create(command CreateEntryCommand) (TypedEntry, err
if err := ensureMediaExists(u.images, owner, data, nil); err != nil { if err := ensureMediaExists(u.images, owner, data, nil); err != nil {
return TypedEntry{}, err return TypedEntry{}, err
} }
resolvers := newEntryResolvers(u.images, u.contentTypes, u.entries)
if err := ensureRelationsExist(resolvers.relations, owner, data, nil); err != nil {
return TypedEntry{}, err
}
if err := ensureUnique(u.entries, owner, data, 0); err != nil { if err := ensureUnique(u.entries, owner, data, 0); err != nil {
return TypedEntry{}, err return TypedEntry{}, err
} }
@@ -51,5 +55,5 @@ func (u *CreateEntryUseCase) Create(command CreateEntryCommand) (TypedEntry, err
if err != nil { if err != nil {
return TypedEntry{}, err return TypedEntry{}, err
} }
return typedEntry(owner, stored, newMediaResolver(u.images)) return typedEntry(owner, stored, resolvers)
} }
@@ -19,6 +19,15 @@ func (u *DeleteContentTypeUseCase) Delete(key string) error {
if err != nil { if err != nil {
return err return err
} }
types, err := u.contentTypes.List()
if err != nil {
return err
}
for _, other := range types {
if other.ID != target.ID && contenttype.ReferencesType(other.Fields, target.Key) {
return contenttype.ErrReferenced
}
}
count, err := u.entries.CountByContentType(target.ID) count, err := u.entries.CountByContentType(target.ID)
if err != nil { if err != nil {
return err return err
+1 -1
View File
@@ -25,5 +25,5 @@ func (u *GetEntryUseCase) Get(contentTypeKey string, id uint32) (TypedEntry, err
if err != nil { if err != nil {
return TypedEntry{}, err return TypedEntry{}, err
} }
return typedEntry(owner, found, newMediaResolver(u.images)) return typedEntry(owner, found, newEntryResolvers(u.images, u.contentTypes, u.entries))
} }
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/contenttype" "RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry" "RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"strings"
) )
type ListEntriesUseCase struct { type ListEntriesUseCase struct {
@@ -17,6 +18,7 @@ type ListEntriesCommand struct {
Page int Page int
Size int Size int
Sort entry.Sort Sort entry.Sort
Query string
} }
type EntryPage struct { type EntryPage struct {
@@ -41,12 +43,12 @@ func (u *ListEntriesUseCase) ListPage(command ListEntriesCommand) (EntryPage, er
if err != nil { if err != nil {
return EntryPage{}, err return EntryPage{}, err
} }
found, err := u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Page: command.Page, Size: command.Size, Sort: command.Sort}) found, err := u.page(owner, command)
if err != nil { if err != nil {
return EntryPage{}, err return EntryPage{}, err
} }
items := make([]TypedEntry, 0, len(found.Items)) items := make([]TypedEntry, 0, len(found.Items))
resolver := newMediaResolver(u.images) resolver := newEntryResolvers(u.images, u.contentTypes, u.entries)
for _, record := range found.Items { for _, record := range found.Items {
typed, err := typedEntry(owner, record, resolver) typed, err := typedEntry(owner, record, resolver)
if err != nil { if err != nil {
@@ -56,3 +58,25 @@ func (u *ListEntriesUseCase) ListPage(command ListEntriesCommand) (EntryPage, er
} }
return EntryPage{Items: items, Total: found.Total, Page: command.Page, Size: command.Size}, nil return EntryPage{Items: items, Total: found.Total, Page: command.Page, Size: command.Size}, nil
} }
func (u *ListEntriesUseCase) page(owner contenttype.ContentType, command ListEntriesCommand) (entry.PageResult, error) {
query := strings.TrimSpace(command.Query)
if query == "" {
return u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Page: command.Page, Size: command.Size, Sort: command.Sort})
}
all, err := u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Unpaged: true, Sort: command.Sort})
if err != nil {
return entry.PageResult{}, err
}
matching := make([]entry.Entry, 0, len(all.Items))
for _, record := range all.Items {
if entry.MatchesQuery(owner.Fields, record.Data, query) {
matching = append(matching, record)
}
}
pageNumber := max(command.Page, 1)
size := max(command.Size, 0)
start := min((pageNumber-1)*size, len(matching))
end := min(start+size, len(matching))
return entry.PageResult{Items: matching[start:end], Total: len(matching)}, nil
}
@@ -39,6 +39,12 @@ func (u *UpdateContentTypeUseCase) Update(command UpdateContentTypeCommand) (con
if err != nil { if err != nil {
return contenttype.ContentType{}, err return contenttype.ContentType{}, err
} }
if relationShapeChanged(current.Fields, fields) {
return contenttype.ContentType{}, contenttype.ErrInvalidField
}
if err := ensureRelationTargets(u.contentTypes, current.Key, fields); err != nil {
return contenttype.ContentType{}, err
}
stored, err := u.storedEntries(current.ID) stored, err := u.storedEntries(current.ID)
if err != nil { if err != nil {
return contenttype.ContentType{}, err return contenttype.ContentType{}, err
@@ -40,6 +40,10 @@ func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, err
if err := ensureMediaExists(u.images, owner, command.Data, current.Data); err != nil { if err := ensureMediaExists(u.images, owner, command.Data, current.Data); err != nil {
return TypedEntry{}, err return TypedEntry{}, err
} }
resolvers := newEntryResolvers(u.images, u.contentTypes, u.entries)
if err := ensureRelationsExist(resolvers.relations, owner, command.Data, current.Data); err != nil {
return TypedEntry{}, err
}
if err := ensureUnique(u.entries, owner, command.Data, current.ID); err != nil { if err := ensureUnique(u.entries, owner, command.Data, current.ID); err != nil {
return TypedEntry{}, err return TypedEntry{}, err
} }
@@ -50,5 +54,5 @@ func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, err
if err != nil { if err != nil {
return TypedEntry{}, err return TypedEntry{}, err
} }
return typedEntry(owner, stored, newMediaResolver(u.images)) return typedEntry(owner, stored, resolvers)
} }
@@ -0,0 +1,168 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"errors"
)
type EntryFirstField struct {
Key string
Type contenttype.FieldType
Value any
}
type EntryRelation struct {
ID uint32
Missing bool
ContentType string
FirstField EntryFirstField
}
type EntryRelations struct {
Many bool
Items []EntryRelation
}
type targetEntryKey struct {
contentType string
id uint32
}
type relationResolver struct {
contentTypes contenttype.Repository
entries entry.Repository
types map[string]*contenttype.ContentType
found map[targetEntryKey]EntryRelation
}
func newRelationResolver(contentTypes contenttype.Repository, entries entry.Repository) *relationResolver {
return &relationResolver{contentTypes: contentTypes, entries: entries, types: map[string]*contenttype.ContentType{}, found: map[targetEntryKey]EntryRelation{}}
}
func (r *relationResolver) resolve(owner contenttype.ContentType, record entry.Entry) (map[string]EntryRelations, error) {
resolved := map[string]EntryRelations{}
for _, ref := range contenttype.RelationRefs(owner.Fields, record.Data) {
items := make([]EntryRelation, 0, len(ref.IDs))
for _, id := range ref.IDs {
item, err := r.lookup(ref.Field.Relation.ContentType, id)
if err != nil {
return nil, err
}
items = append(items, item)
}
resolved[ref.Field.Key] = EntryRelations{Many: ref.Field.HoldsManyRelations(), Items: items}
}
return resolved, nil
}
func (r *relationResolver) targetType(key string) (*contenttype.ContentType, error) {
if cached, found := r.types[key]; found {
return cached, nil
}
target, err := r.contentTypes.GetByKey(key)
if errors.Is(err, contenttype.ErrContentTypeNotFound) {
r.types[key] = nil
return nil, nil
}
if err != nil {
return nil, err
}
r.types[key] = &target
return &target, nil
}
func (r *relationResolver) lookup(typeKey string, id uint32) (EntryRelation, error) {
cacheKey := targetEntryKey{typeKey, id}
if cached, found := r.found[cacheKey]; found {
return cached, nil
}
target, err := r.fetch(typeKey, id)
if err != nil {
return EntryRelation{}, err
}
r.found[cacheKey] = target
return target, nil
}
func (r *relationResolver) fetch(typeKey string, id uint32) (EntryRelation, error) {
missing := EntryRelation{ID: id, Missing: true}
target, err := r.targetType(typeKey)
if err != nil || target == nil || len(target.Fields) == 0 {
return missing, err
}
record, err := r.entries.Get(target.ID, id)
if errors.Is(err, entry.ErrEntryNotFound) {
return missing, nil
}
if err != nil {
return EntryRelation{}, err
}
first := target.Fields[0]
return EntryRelation{ID: id, ContentType: typeKey, FirstField: EntryFirstField{Key: first.Key, Type: first.Type, Value: record.Data[first.Key]}}, nil
}
func (r *relationResolver) exists(typeKey string, id uint32) (bool, error) {
target, err := r.targetType(typeKey)
if err != nil || target == nil || len(target.Fields) == 0 {
return false, err
}
_, err = r.entries.Get(target.ID, id)
if errors.Is(err, entry.ErrEntryNotFound) {
return false, nil
}
return err == nil, err
}
func ensureRelationsExist(resolver *relationResolver, owner contenttype.ContentType, data map[string]any, stored map[string]any) error {
storedIDs := entry.RelationIDs(owner.Fields, stored)
for _, ref := range contenttype.RelationRefs(owner.Fields, data) {
for _, id := range ref.IDs {
unchanged := stored != nil && storedIDs[ref.Field.Key][id]
if unchanged && !ref.Field.Required {
continue
}
exists, err := resolver.exists(ref.Field.Relation.ContentType, id)
if err != nil {
return err
}
if !exists {
return entry.MissingRelation(ref.Field, unchanged)
}
}
}
return nil
}
func ensureRelationTargets(contentTypes contenttype.Repository, ownerKey string, fields []contenttype.Field) error {
for _, field := range contenttype.RelationFields(fields) {
target := field.Relation.ContentType
if target == ownerKey {
if field.IsExclusiveRelation() {
return contenttype.ErrInvalidField
}
continue
}
_, err := contentTypes.GetByKey(target)
if errors.Is(err, contenttype.ErrContentTypeNotFound) {
return contenttype.ErrInvalidField
}
if err != nil {
return err
}
}
return nil
}
func relationShapeChanged(previous []contenttype.Field, next []contenttype.Field) bool {
nextByKey := make(map[string]contenttype.Field, len(next))
for _, field := range next {
nextByKey[field.Key] = field
}
for _, field := range contenttype.RelationFields(previous) {
if replacement, kept := nextByKey[field.Key]; kept && !contenttype.SameRelation(field, replacement) {
return true
}
}
return false
}
@@ -11,15 +11,29 @@ type TypedEntry struct {
entry.Entry entry.Entry
ContentTypeKey string ContentTypeKey string
Media map[string]EntryMedia Media map[string]EntryMedia
Relations map[string]EntryRelations
} }
func typedEntry(owner contenttype.ContentType, record entry.Entry, resolver *mediaResolver) (TypedEntry, error) { type entryResolvers struct {
media *mediaResolver
relations *relationResolver
}
func newEntryResolvers(images image.Repository, contentTypes contenttype.Repository, entries entry.Repository) *entryResolvers {
return &entryResolvers{media: newMediaResolver(images), relations: newRelationResolver(contentTypes, entries)}
}
func typedEntry(owner contenttype.ContentType, record entry.Entry, resolvers *entryResolvers) (TypedEntry, error) {
record.Data = entry.Project(owner.Fields, record.Data) record.Data = entry.Project(owner.Fields, record.Data)
media, err := resolver.resolve(owner, record) media, err := resolvers.media.resolve(owner, record)
if err != nil { if err != nil {
return TypedEntry{}, err return TypedEntry{}, err
} }
return TypedEntry{Entry: record, ContentTypeKey: owner.Key, Media: media}, nil relations, err := resolvers.relations.resolve(owner, record)
if err != nil {
return TypedEntry{}, err
}
return TypedEntry{Entry: record, ContentTypeKey: owner.Key, Media: media, Relations: relations}, nil
} }
func ensureMediaExists(images image.Repository, owner contenttype.ContentType, data map[string]any, stored map[string]any) error { func ensureMediaExists(images image.Repository, owner contenttype.ContentType, data map[string]any, stored map[string]any) error {
@@ -35,7 +35,7 @@ const supportRecipeKey = "recipe"
var supportContentTypeKeys = []string{"created_at", "fields", "icon", "id", "key", "name", "updated_at"} var supportContentTypeKeys = []string{"created_at", "fields", "icon", "id", "key", "name", "updated_at"}
var supportFieldKeys = []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"} var supportFieldKeys = []string{"default", "key", "label", "max", "min", "options", "relation", "required", "type", "unique"}
func newSupportEnv(t *testing.T) *contentTypeEnv { func newSupportEnv(t *testing.T) *contentTypeEnv {
t.Helper() t.Helper()
+2 -2
View File
@@ -12,8 +12,8 @@ import (
var ( var (
contentTypeKeys = []string{"created_at", "fields", "icon", "id", "key", "name", "updated_at"} contentTypeKeys = []string{"created_at", "fields", "icon", "id", "key", "name", "updated_at"}
fieldKeys = []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"} fieldKeys = []string{"default", "key", "label", "max", "min", "options", "relation", "required", "type", "unique"}
entryKeys = []string{"content_type", "created_at", "created_by", "data", "id", "media", "updated_at", "updated_by"} entryKeys = []string{"content_type", "created_at", "created_by", "data", "id", "media", "relations", "updated_at", "updated_by"}
) )
func TestCreatingAContentType(t *testing.T) { func TestCreatingAContentType(t *testing.T) {
+1 -1
View File
@@ -628,7 +628,7 @@ func TestContentTypeStoredFormat(t *testing.T) {
keys = append(keys, key) keys = append(keys, key)
} }
sort.Strings(keys) sort.Strings(keys)
expected := []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"} expected := []string{"default", "key", "label", "max", "min", "options", "relation", "required", "type", "unique"}
if !reflect.DeepEqual(keys, expected) { if !reflect.DeepEqual(keys, expected) {
t.Errorf("stored keys %v", keys) t.Errorf("stored keys %v", keys)
} }
@@ -0,0 +1,66 @@
package test
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"testing"
)
func TestRelationFieldMediaIDBounds(t *testing.T) {
cases := []struct {
name string
value any
id uint32
ok bool
}{
{"uint32 one", uint32(1), 1, true},
{"uint32 zero", uint32(0), 0, false},
{"int one", 1, 1, true},
{"int zero", 0, 0, false},
{"int negative", -4, 0, false},
{"int64 max uint32", int64(4294967295), 4294967295, true},
{"int64 over uint32", int64(4294967296), 0, false},
{"int64 zero", int64(0), 0, false},
{"float whole", float64(7), 7, true},
{"float fraction", 7.5, 0, false},
{"float zero", float64(0), 0, false},
{"float max uint32", float64(4294967295), 4294967295, true},
{"float over uint32", float64(4294967296), 0, false},
{"string", "7", 0, false},
}
for _, c := range cases {
id, ok := contenttype.MediaID(c.value)
if ok != c.ok || id != c.id {
t.Errorf("%s: got (%d, %v), want (%d, %v)", c.name, id, ok, c.id, c.ok)
}
}
}
func TestRelationFieldQueryDisplayText(t *testing.T) {
text := []contenttype.Field{{Key: "name", Type: contenttype.TypeText}}
number := []contenttype.Field{{Key: "n", Type: contenttype.TypeNumber}}
if entry.MatchesQuery(nil, map[string]any{"name": "x"}, "x") {
t.Error("no fields must never match")
}
if !entry.MatchesQuery(text, map[string]any{"name": "Grand Hall"}, "grand") {
t.Error("case-insensitive text match expected")
}
if entry.MatchesQuery(text, map[string]any{}, "grand") {
t.Error("missing value must not match")
}
if !entry.MatchesQuery(text, map[string]any{}, "") {
t.Error("empty query matches an empty display text")
}
if !entry.MatchesQuery(number, map[string]any{"n": 3.5}, "3.5") {
t.Error("float must render without exponent or padding")
}
if !entry.MatchesQuery(number, map[string]any{"n": float64(42)}, "42") {
t.Error("whole float must render without decimals")
}
if entry.MatchesQuery(number, map[string]any{"n": float64(42)}, "42.0") {
t.Error("whole float must not render a decimal part")
}
if !entry.MatchesQuery(number, map[string]any{"n": true}, "true") {
t.Error("other kinds fall back to default formatting")
}
}
+539
View File
@@ -0,0 +1,539 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/entry"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
)
type relationEnv struct {
*contentTypeEnv
}
func relationField(key string, label string, target string, kind string, required bool) map[string]any {
return map[string]any{"key": key, "label": label, "type": "relation", "required": required, "unique": false, "default": nil, "min": nil, "max": nil, "options": []string{}, "relation": map[string]any{"content_type": target, "kind": kind}}
}
func eventRelationFields(venueRequired bool, speakersRequired bool) []map[string]any {
return []map[string]any{
{"key": "title", "label": "Title", "type": "text", "required": true},
relationField("venue", "Venue", "venue", "one_to_many", venueRequired),
relationField("speakers", "Speakers", "speaker", "many_to_many", speakersRequired),
}
}
func newRelationEnv(t *testing.T, venueRequired bool, speakersRequired bool) *relationEnv {
t.Helper()
env := &relationEnv{newContentTypeEnv(t)}
env.createType(t, "venue", []map[string]any{{"key": "title", "label": "Title", "type": "text", "required": true}, {"key": "city", "label": "City", "type": "text"}})
env.createType(t, "speaker", []map[string]any{{"key": "name", "label": "Name", "type": "text", "required": true}})
env.createType(t, "event", eventRelationFields(venueRequired, speakersRequired))
env.seed(t, "venue", 3, "title", "Rue Verte hall")
env.seed(t, "venue", 4, "title", "Rue Verte yard")
env.seed(t, "speaker", 11, "name", "Ines Duarte")
env.seed(t, "speaker", 12, "name", "Jonas Pereira")
env.entries.nextID = 100
return env
}
func (e *relationEnv) seed(t *testing.T, typeKey string, id uint32, field string, value any) {
t.Helper()
e.seedData(t, typeKey, id, map[string]any{field: value})
}
func (e *relationEnv) seedData(t *testing.T, typeKey string, id uint32, data map[string]any) {
t.Helper()
owner, err := e.types.GetByKey(typeKey)
if err != nil {
t.Fatalf("seed type %s: %v", typeKey, err)
}
e.entries.entries[id] = entry.Entry{ID: id, ContentTypeID: owner.ID, Data: data, CreatedBy: ctMemberId, UpdatedBy: ctMemberId, CreatedAt: e.clock, UpdatedAt: e.clock}
}
func (e *relationEnv) member(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper()
return e.as(t, ctMemberId, method, path, body)
}
func (e *relationEnv) postEvent(t *testing.T, data string) *httptest.ResponseRecorder {
t.Helper()
return e.createEntry(t, "event", ctMemberId, data)
}
func (e *relationEnv) putEvent(t *testing.T, id uint32, data string) *httptest.ResponseRecorder {
t.Helper()
return e.member(t, http.MethodPut, "/v1/content-type/event/entry/"+strconv.Itoa(int(id)), `{"data": `+data+`}`)
}
func (e *relationEnv) countEntries(typeKey string) int {
owner, _ := e.types.GetByKey(typeKey)
count, _ := e.entries.CountByContentType(owner.ID)
return count
}
func hall(id float64, key string, value string, typeKey string, fieldType string) map[string]any {
return map[string]any{"id": id, "content_type": typeKey, "first_field": map[string]any{"key": key, "type": fieldType, "value": value}}
}
func TestRelationFieldTypeContentTypes(t *testing.T) {
t.Run("Creating a content type with a relation field", func(t *testing.T) {
env := newContentTypeEnv(t)
fields := []map[string]any{{"key": "title", "label": "Title", "type": "text", "required": true}, relationField("venue", "Venue", "venue", "one_to_many", false)}
rec := env.admin(t, http.MethodPost, "/v1/content-type", typeBody("venue", "Venue", fields))
if rec.Code != http.StatusCreated {
t.Fatalf("status %d %s", rec.Code, rec.Body.String())
}
stored := decodeContentObject(t, rec)["fields"].([]any)
if stored[0].(map[string]any)["relation"] != nil || !reflect.DeepEqual(objectKeys(stored[1].(map[string]any)), fieldKeys) {
t.Errorf("fields %v", stored)
}
if !reflect.DeepEqual(stored[1].(map[string]any)["relation"], map[string]any{"content_type": "venue", "kind": "one_to_many"}) {
t.Errorf("relation %v", stored[1])
}
})
t.Run("A relation field with a bad definition is refused", func(t *testing.T) {
defects := map[string]func(map[string]any){
"relation null": func(f map[string]any) { f["relation"] = nil },
"content_type nope": func(f map[string]any) { f["relation"] = map[string]any{"content_type": "nope", "kind": "one_to_many"} },
"content_type empty": func(f map[string]any) { f["relation"] = map[string]any{"content_type": "", "kind": "one_to_many"} },
"content_type missing": func(f map[string]any) { f["relation"] = map[string]any{"kind": "one_to_many"} },
"kind many_to_one": func(f map[string]any) {
f["relation"] = map[string]any{"content_type": "speaker", "kind": "many_to_one"}
},
"kind missing": func(f map[string]any) { f["relation"] = map[string]any{"content_type": "speaker"} },
"a relation object on text": func(f map[string]any) { f["type"] = "text" },
"unique true": func(f map[string]any) { f["unique"] = true },
"default 3": func(f map[string]any) { f["default"] = 3 },
"min 1": func(f map[string]any) { f["min"] = 1 },
"max 5": func(f map[string]any) { f["max"] = 5 },
"options a": func(f map[string]any) { f["options"] = []string{"a"} },
"a one_to_one self relation": func(f map[string]any) { f["relation"] = map[string]any{"content_type": "talk", "kind": "one_to_one"} },
"an invalid field key": func(f map[string]any) { f["key"] = "Bad Key" },
}
for name, defect := range defects {
t.Run(name, func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "speaker", []map[string]any{{"key": "name", "label": "Name", "type": "text"}})
field := relationField("link", "Link", "speaker", "one_to_many", false)
defect(field)
rec := env.admin(t, http.MethodPost, "/v1/content-type", typeBody("talk", "Talk", []map[string]any{field}))
expectStatusAndBody(t, rec, http.StatusUnprocessableEntity, apperr.InvalidFieldErrorMessage)
if _, err := env.types.GetByKey("talk"); err == nil {
t.Error("talk should not be stored")
}
})
}
})
t.Run("A self relation is allowed for one_to_many and many_to_many", func(t *testing.T) {
env := newContentTypeEnv(t)
for key, kind := range map[string]string{"selfmany": "one_to_many", "selfpool": "many_to_many"} {
fields := []map[string]any{{"key": "title", "label": "Title", "type": "text"}, relationField("parent", "Parent", key, kind, false)}
rec := env.admin(t, http.MethodPost, "/v1/content-type", typeBody(key, "Self", fields))
if rec.Code != http.StatusCreated {
t.Errorf("%s: %d %s", kind, rec.Code, rec.Body.String())
}
}
})
t.Run("The shape of a saved relation never changes", func(t *testing.T) {
changes := map[string]func(fields []map[string]any){
"kind to many_to_many": func(f []map[string]any) {
f[1]["relation"] = map[string]any{"content_type": "venue", "kind": "many_to_many"}
},
"target to speaker": func(f []map[string]any) {
f[1]["relation"] = map[string]any{"content_type": "speaker", "kind": "one_to_many"}
},
"turned into text": func(f []map[string]any) {
f[1]["type"] = "text"
f[1]["relation"] = nil
},
}
for name, change := range changes {
t.Run(name, func(t *testing.T) {
env := newRelationEnv(t, false, false)
fields := eventRelationFields(false, false)
change(fields)
rec := env.admin(t, http.MethodPut, "/v1/content-type/event", typeBody("event", "Event", fields))
expectStatusAndBody(t, rec, http.StatusUnprocessableEntity, apperr.InvalidFieldErrorMessage)
stored, _ := env.types.GetByKey("event")
if stored.Fields[1].Relation == nil || stored.Fields[1].Relation.ContentType != "venue" {
t.Errorf("schema changed: %v", stored.Fields[1])
}
})
}
})
t.Run("Adding a relation field to a type that has entries", func(t *testing.T) {
for _, example := range []struct {
required bool
status int
message string
}{{true, http.StatusConflict, apperr.SchemaIncompatibleErrorMessage}, {false, http.StatusOK, ""}} {
t.Run(strconv.FormatBool(example.required), func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "venue", []map[string]any{{"key": "title", "label": "Title", "type": "text"}})
env.createType(t, "event", []map[string]any{{"key": "title", "label": "Title", "type": "text"}})
for range 12 {
env.createEntry(t, "event", ctMemberId, `{"title":"A"}`)
}
fields := []map[string]any{{"key": "title", "label": "Title", "type": "text"}, relationField("venue", "Venue", "venue", "one_to_many", example.required)}
rec := env.admin(t, http.MethodPut, "/v1/content-type/event", typeBody("event", "Event", fields))
expectStatusAndBody(t, rec, example.status, example.message)
})
}
})
t.Run("Deleting a type that another type points at", func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.entries.entries = map[uint32]entry.Entry{}
rec := env.admin(t, http.MethodDelete, "/v1/content-type/venue", "")
expectStatusAndBody(t, rec, http.StatusConflict, apperr.ContentTypeReferencedErrorMessage)
if _, err := env.types.GetByKey("venue"); err != nil {
t.Error("venue should still exist")
}
fields := []map[string]any{{"key": "title", "label": "Title", "type": "text", "required": true}, relationField("speakers", "Speakers", "speaker", "many_to_many", false)}
env.admin(t, http.MethodPut, "/v1/content-type/event", typeBody("event", "Event", fields))
expectStatusAndBody(t, env.admin(t, http.MethodDelete, "/v1/content-type/venue", ""), http.StatusNoContent, "")
})
}
func TestRelationFieldTypeEntries(t *testing.T) {
t.Run("Creating an entry with a single target", func(t *testing.T) {
env := newRelationEnv(t, false, false)
rec := env.postEvent(t, `{"title":"Lime plaster workshop","venue":3}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status %d %s", rec.Code, rec.Body.String())
}
body := decodeContentObject(t, rec)
want := map[string]any{"venue": hall(3, "title", "Rue Verte hall", "venue", "text")}
if !reflect.DeepEqual(objectKeys(body), entryKeys) || body["data"].(map[string]any)["venue"] != float64(3) || !reflect.DeepEqual(body["relations"], want) {
t.Errorf("body %s", rec.Body.String())
}
})
t.Run("Creating an entry with many targets", func(t *testing.T) {
env := newRelationEnv(t, false, false)
rec := env.postEvent(t, `{"title":"A","speakers":[12,11]}`)
body := decodeContentObject(t, rec)
want := []any{hall(12, "name", "Jonas Pereira", "speaker", "text"), hall(11, "name", "Ines Duarte", "speaker", "text")}
if rec.Code != http.StatusCreated || !reflect.DeepEqual(body["relations"].(map[string]any)["speakers"], want) || !reflect.DeepEqual(body["data"].(map[string]any)["speakers"], []any{float64(12), float64(11)}) {
t.Errorf("body %s", rec.Body.String())
}
})
t.Run("Empty optional relations", func(t *testing.T) {
for _, example := range []struct{ data, key string }{{`{"title":"A","venue":null}`, "venue"}, {`{"title":"A"}`, "venue"}, {`{"title":"A","speakers":[]}`, "speakers"}} {
t.Run(example.data, func(t *testing.T) {
env := newRelationEnv(t, false, false)
rec := env.postEvent(t, example.data)
body := decodeContentObject(t, rec)
if rec.Code != http.StatusCreated || !reflect.DeepEqual(body["relations"], map[string]any{}) {
t.Errorf("body %s", rec.Body.String())
}
})
}
})
t.Run("Values that break a relation are refused and store nothing", func(t *testing.T) {
fiftyOne := make([]string, 51)
for index := range fiftyOne {
fiftyOne[index] = strconv.Itoa(index + 1)
}
for _, example := range []struct {
required bool
data string
message string
}{
{true, `{"title":"A"}`, "invalid_entry: venue: required"},
{true, `{"title":"A","venue":null}`, "invalid_entry: venue: required"},
{false, `{"title":"A","venue":999}`, "invalid_entry: venue: type"},
{false, `{"title":"A","venue":"3"}`, "invalid_entry: venue: type"},
{false, `{"title":"A","venue":0}`, "invalid_entry: venue: type"},
{false, `{"title":"A","venue":3.5}`, "invalid_entry: venue: type"},
{false, `{"title":"A","venue":[3]}`, "invalid_entry: venue: type"},
{false, `{"title":"A","speakers":11}`, "invalid_entry: speakers: type"},
{false, `{"title":"A","speakers":[11,11]}`, "invalid_entry: speakers: type"},
{false, `{"title":"A","speakers":[11,999]}`, "invalid_entry: speakers: type"},
{false, `{"title":"A","speakers":[3]}`, "invalid_entry: speakers: type"},
{false, `{"title":"A","speakers":[` + strings.Join(fiftyOne, ",") + `]}`, "invalid_entry: speakers: type"},
} {
t.Run(example.data[:min(len(example.data), 40)], func(t *testing.T) {
env := newRelationEnv(t, example.required, false)
rec := env.postEvent(t, example.data)
expectStatusAndBody(t, rec, http.StatusUnprocessableEntity, example.message)
if env.countEntries("event") != 0 {
t.Error("an entry was stored")
}
})
}
})
t.Run("A required many relation is never empty", func(t *testing.T) {
env := newRelationEnv(t, false, true)
expectStatusAndBody(t, env.postEvent(t, `{"title":"A","speakers":[]}`), http.StatusUnprocessableEntity, "invalid_entry: speakers: required")
expectStatusAndBody(t, env.postEvent(t, `{"title":"A"}`), http.StatusUnprocessableEntity, "invalid_entry: speakers: required")
})
t.Run("One to one keeps targets exclusive", func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.createType(t, "talk", []map[string]any{{"key": "title", "label": "Title", "type": "text"}, relationField("room", "Room", "venue", "one_to_one", false)})
env.seedData(t, "talk", 50, map[string]any{"title": "Holder", "room": float64(3)})
post := func() *httptest.ResponseRecorder {
return env.createEntry(t, "talk", ctMemberId, `{"title":"B","room":3}`)
}
expectStatusAndBody(t, post(), http.StatusConflict, "invalid_entry: room: unique")
if env.countEntries("talk") != 1 {
t.Error("a talk was stored")
}
unchanged := env.member(t, http.MethodPut, "/v1/content-type/talk/entry/50", `{"data":{"title":"Holder","room":3}}`)
if unchanged.Code != http.StatusOK {
t.Errorf("unchanged holder: %d %s", unchanged.Code, unchanged.Body.String())
}
env.member(t, http.MethodDelete, "/v1/content-type/talk/entry/50", "")
if rec := post(); rec.Code != http.StatusCreated {
t.Errorf("after delete: %d %s", rec.Code, rec.Body.String())
}
})
t.Run("Replacing and clearing targets", func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.seedData(t, "event", 5, map[string]any{"title": "E", "venue": float64(3), "speakers": []any{float64(11)}})
rec := env.putEvent(t, 5, `{"title":"E","venue":4,"speakers":[11,12]}`)
body := decodeContentObject(t, rec)
relations := body["relations"].(map[string]any)
if rec.Code != http.StatusOK || relations["venue"].(map[string]any)["id"] != float64(4) || len(relations["speakers"].([]any)) != 2 || body["data"].(map[string]any)["venue"] != float64(4) {
t.Errorf("body %s", rec.Body.String())
}
cleared := decodeContentObject(t, env.putEvent(t, 5, `{"title":"E","venue":null,"speakers":[]}`))
if !reflect.DeepEqual(cleared["relations"], map[string]any{}) {
t.Errorf("cleared %v", cleared)
}
})
t.Run("A target deleted after the entry was saved", func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.seedData(t, "event", 5, map[string]any{"title": "E", "venue": float64(3), "speakers": []any{float64(11), float64(12)}})
before := env.entries.entries[5]
expectStatusAndBody(t, env.member(t, http.MethodDelete, "/v1/content-type/venue/entry/3", ""), http.StatusNoContent, "")
expectStatusAndBody(t, env.member(t, http.MethodDelete, "/v1/content-type/speaker/entry/12", ""), http.StatusNoContent, "")
rec := env.member(t, http.MethodGet, "/v1/content-type/event/entry/5", "")
body := decodeContentObject(t, rec)
relations := body["relations"].(map[string]any)
wantSpeakers := []any{hall(11, "name", "Ines Duarte", "speaker", "text"), map[string]any{"id": float64(12), "missing": true}}
if !reflect.DeepEqual(relations["venue"], map[string]any{"id": float64(3), "missing": true}) || !reflect.DeepEqual(relations["speakers"], wantSpeakers) {
t.Errorf("relations %v", relations)
}
if !reflect.DeepEqual(body["data"].(map[string]any)["speakers"], []any{float64(11), float64(12)}) || !reflect.DeepEqual(env.entries.entries[5], before) {
t.Errorf("entry modified: %v", env.entries.entries[5])
}
})
t.Run("Unchanged and changed ids when the target is gone", func(t *testing.T) {
for _, example := range []struct {
value string
status int
message string
}{{"3", 200, ""}, {"999", 422, "invalid_entry: venue: type"}, {"4", 200, ""}, {"null", 200, ""}} {
t.Run(example.value, func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.seedData(t, "event", 5, map[string]any{"title": "E", "venue": float64(3)})
delete(env.entries.entries, 3)
rec := env.putEvent(t, 5, `{"title":"E","venue":`+example.value+`}`)
expectStatusAndBody(t, rec, example.status, example.message)
})
}
})
t.Run("A required relation with a missing target can't be saved as is", func(t *testing.T) {
for _, example := range []struct {
value string
status int
message string
unchanged bool
}{{"3", 422, "invalid_entry: venue: required", true}, {"null", 422, "invalid_entry: venue: required", true}, {"4", 200, "", false}} {
t.Run(example.value, func(t *testing.T) {
env := newRelationEnv(t, true, false)
env.seedData(t, "event", 5, map[string]any{"title": "E", "venue": float64(3)})
delete(env.entries.entries, 3)
before := env.entries.entries[5]
rec := env.putEvent(t, 5, `{"title":"E","venue":`+example.value+`}`)
expectStatusAndBody(t, rec, example.status, example.message)
if example.unchanged && !reflect.DeepEqual(env.entries.entries[5], before) {
t.Error("the stored entry changed")
}
})
}
})
t.Run("A create always treats the id as changed", func(t *testing.T) {
env := newRelationEnv(t, false, false)
delete(env.entries.entries, 3)
expectStatusAndBody(t, env.postEvent(t, `{"title":"A","venue":3}`), http.StatusUnprocessableEntity, "invalid_entry: venue: type")
})
t.Run("Two broken fields name only one", func(t *testing.T) {
env := newRelationEnv(t, false, false)
expectStatusAndBody(t, env.postEvent(t, `{"title":"","venue":999}`), http.StatusUnprocessableEntity, "invalid_entry: venue: type")
expectStatusAndBody(t, env.postEvent(t, `{"venue":999}`), http.StatusUnprocessableEntity, "invalid_entry: title: required")
})
t.Run("Entry list responses carry relations per item", func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.seedData(t, "event", 5, map[string]any{"title": "One", "venue": float64(3), "speakers": []any{float64(11), float64(12)}})
env.seedData(t, "event", 6, map[string]any{"title": "Two", "venue": float64(99)})
env.seedData(t, "event", 7, map[string]any{"title": "Three"})
rec := env.member(t, http.MethodGet, "/v1/content-type/event/entry?sort=id:asc", "")
var page struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil || page.Total != 3 || len(page.Items) != 3 {
t.Fatalf("page %s", rec.Body.String())
}
for _, item := range page.Items {
if !reflect.DeepEqual(objectKeys(item), entryKeys) {
t.Errorf("keys %v", objectKeys(item))
}
}
if len(page.Items[0]["relations"].(map[string]any)) != 2 || page.Items[1]["relations"].(map[string]any)["venue"].(map[string]any)["missing"] != true || len(page.Items[2]["relations"].(map[string]any)) != 0 {
t.Errorf("relations %s", rec.Body.String())
}
})
t.Run("A type without a relation field returns empty relations and makes no target call", func(t *testing.T) {
env := newRelationEnv(t, false, false)
before := env.entries.getCalls
rec := env.member(t, http.MethodGet, "/v1/content-type/venue/entry/3", "")
body := decodeContentObject(t, rec)
if !reflect.DeepEqual(body["relations"], map[string]any{}) || env.entries.getCalls-before != 1 {
t.Errorf("body %s calls %d", rec.Body.String(), env.entries.getCalls-before)
}
})
t.Run("Many entries pointing at one target fetch it once", func(t *testing.T) {
env := newRelationEnv(t, false, false)
for id := uint32(200); id < 300; id++ {
env.seedData(t, "event", id, map[string]any{"title": "Event", "venue": float64(3)})
}
before := env.entries.getCalls
rec := env.member(t, http.MethodGet, "/v1/content-type/event/entry?size=100", "")
var page struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil || len(page.Items) != 100 {
t.Fatalf("status %d items %d", rec.Code, len(page.Items))
}
if calls := env.entries.getCalls - before; calls > 1 {
t.Errorf("get calls %d", calls)
}
})
t.Run("Searching the entries of a type", func(t *testing.T) {
for _, example := range []struct {
q string
total int
}{{"rue", 3}, {"RUE%20VERTE", 2}, {"yard", 1}, {"nothing", 0}, {"", 3}, {"%20%20", 3}} {
t.Run(example.q, func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.seed(t, "venue", 5, "title", "Rue du Port studio")
rec := env.member(t, http.MethodGet, "/v1/content-type/venue/entry?q="+example.q, "")
var page struct {
Total int `json:"total"`
}
json.Unmarshal(rec.Body.Bytes(), &page)
if rec.Code != http.StatusOK || page.Total != example.total {
t.Errorf("total %d: %s", page.Total, rec.Body.String())
}
})
}
})
t.Run("Search is paginated after filtering", func(t *testing.T) {
env := newRelationEnv(t, false, false)
for id := uint32(20); id < 50; id++ {
env.seed(t, "venue", id, "title", "Rue "+strconv.Itoa(int(id)))
}
rec := env.member(t, http.MethodGet, "/v1/content-type/venue/entry?q=rue&page=2&size=5&sort=id:asc", "")
var page struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
json.Unmarshal(rec.Body.Bytes(), &page)
if page.Total != 32 || page.Page != 2 || page.Size != 5 || len(page.Items) != 5 || page.Items[0]["id"] != float64(20+3) {
t.Errorf("page %+v", page)
}
})
t.Run("Deleting an entry that is referenced", func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.seedData(t, "event", 5, map[string]any{"title": "E", "venue": float64(3)})
before := env.entries.entries[5]
expectStatusAndBody(t, env.member(t, http.MethodDelete, "/v1/content-type/venue/entry/3", ""), http.StatusNoContent, "")
if !reflect.DeepEqual(env.entries.entries[5], before) {
t.Error("the referencing entry changed")
}
})
t.Run("The target type, entries and API are unchanged", func(t *testing.T) {
env := newRelationEnv(t, false, false)
typeRec := env.member(t, http.MethodGet, "/v1/content-type/venue", "")
entryRec := env.member(t, http.MethodGet, "/v1/content-type/venue/entry/3", "")
if strings.Contains(typeRec.Body.String(), "Events") || len(decodeContentObject(t, typeRec)["fields"].([]any)) != 2 || strings.Contains(entryRec.Body.String(), "Events") {
t.Errorf("inverse leaked: %s %s", typeRec.Body.String(), entryRec.Body.String())
}
})
}
+253
View File
@@ -0,0 +1,253 @@
package test
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"testing"
"time"
)
func relationSchema(t *testing.T, kind contenttype.RelationKind, required bool) []contenttype.Field {
t.Helper()
fields, err := contenttype.ValidateFields([]contenttype.Field{
{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true},
{Key: "link", Label: "Link", Type: contenttype.TypeRelation, Required: required, Relation: &contenttype.Relation{ContentType: "venue", Kind: kind}},
})
if err != nil {
t.Fatalf("schema: %v", err)
}
return fields
}
func TestRelationFieldDomain(t *testing.T) {
t.Run("contenttype.ValidateFields refuses what a relation field can't have", func(t *testing.T) {
valid := contenttype.Relation{ContentType: "venue", Kind: contenttype.RelationOneToMany}
refused := map[string]contenttype.Field{
"no relation": {},
"no target": {Relation: &contenttype.Relation{Kind: contenttype.RelationOneToMany}},
"bad kind": {Relation: &contenttype.Relation{ContentType: "venue", Kind: "many_to_one"}},
"unique": {Unique: true, Relation: &valid},
"default": {Default: float64(3), Relation: &valid},
"min": {Min: float64(1), Relation: &valid},
"max": {Max: float64(5), Relation: &valid},
"options": {Options: []string{"a"}, Relation: &valid},
}
for name, base := range refused {
base.Key, base.Label, base.Type = "link", "Link", contenttype.TypeRelation
if _, err := contenttype.ValidateFields([]contenttype.Field{base}); !errors.Is(err, contenttype.ErrInvalidField) {
t.Errorf("%s: %v", name, err)
}
}
onText := contenttype.Field{Key: "link", Label: "Link", Type: contenttype.TypeText, Relation: &valid}
if _, err := contenttype.ValidateFields([]contenttype.Field{onText}); !errors.Is(err, contenttype.ErrInvalidField) {
t.Errorf("relation on text: %v", err)
}
})
t.Run("entry.Validate applies required and type to a relation field", func(t *testing.T) {
single := relationSchema(t, contenttype.RelationOneToMany, true)
expectFieldError(t, entry.Validate(single, map[string]any{"title": "A"}), "link", entry.RuleRequired)
expectFieldError(t, entry.Validate(single, map[string]any{"title": "A", "link": nil}), "link", entry.RuleRequired)
expectFieldError(t, entry.Validate(single, map[string]any{"title": "A", "link": "3"}), "link", contenttype.RuleType)
expectFieldError(t, entry.Validate(single, map[string]any{"title": "A", "link": float64(0)}), "link", contenttype.RuleType)
expectFieldError(t, entry.Validate(single, map[string]any{"title": "A", "link": []any{float64(3)}}), "link", contenttype.RuleType)
if err := entry.Validate(single, map[string]any{"title": "A", "link": float64(3)}); err != nil {
t.Errorf("valid single: %v", err)
}
many := relationSchema(t, contenttype.RelationManyToMany, true)
expectFieldError(t, entry.Validate(many, map[string]any{"title": "A", "link": []any{}}), "link", entry.RuleRequired)
expectFieldError(t, entry.Validate(many, map[string]any{"title": "A", "link": float64(3)}), "link", contenttype.RuleType)
expectFieldError(t, entry.Validate(many, map[string]any{"title": "A", "link": []any{float64(3), float64(3)}}), "link", contenttype.RuleType)
if err := entry.Validate(many, map[string]any{"title": "A", "link": []any{float64(4), float64(3)}}); err != nil {
t.Errorf("valid many: %v", err)
}
if err := entry.Validate(relationSchema(t, contenttype.RelationManyToMany, false), map[string]any{"title": "A", "link": []any{}}); err != nil {
t.Errorf("empty optional many: %v", err)
}
})
}
type relationUseCaseEnv struct {
types *mockContentTypeRepo
entries *mockEntryRepo
now func() time.Time
}
func newRelationUseCaseEnv(t *testing.T, kind contenttype.RelationKind) *relationUseCaseEnv {
t.Helper()
env := &relationUseCaseEnv{types: newMockContentTypeRepo(), entries: newMockEntryRepo(), now: func() time.Time { return time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) }}
venue, _ := env.types.Create(contenttype.ContentType{Key: "venue", Name: "Venue", Fields: []contenttype.Field{{Key: "title", Type: contenttype.TypeText}}})
env.entries.entries[3] = entry.Entry{ID: 3, ContentTypeID: venue.ID, Data: map[string]any{"title": "Hall"}}
env.types.Create(contenttype.ContentType{Key: "event", Name: "Event", Fields: relationSchema(t, kind, false)})
return env
}
func TestRelationFieldUseCases(t *testing.T) {
t.Run("CreateEntry and UpdateEntry refuse a missing target and perform no write", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
images := newMockImageRepo()
create := useCases.NewCreateEntryUseCase(env.types, env.entries, images, env.now)
update := useCases.NewUpdateEntryUseCase(env.types, env.entries, images, env.now)
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(999)}, UserID: 1})
expectFieldError(t, err, "link", contenttype.RuleType)
stored, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(3)}, UserID: 1})
if err != nil || env.entries.createCalls != 1 {
t.Fatalf("create: %v", err)
}
_, err = update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "event", ID: stored.ID, Data: map[string]any{"title": "A", "link": float64(999)}, UserID: 1})
expectFieldError(t, err, "link", contenttype.RuleType)
if env.entries.updateCalls != 0 || env.entries.createCalls != 1 {
t.Errorf("writes: %d %d", env.entries.updateCalls, env.entries.createCalls)
}
})
t.Run("UpdateContentType returns the sentinels for a new required relation and a changed shape", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
env.entries.entries[9] = entry.Entry{ID: 9, ContentTypeID: 2, Data: map[string]any{"title": "A"}}
update := useCases.NewUpdateContentTypeUseCase(env.types, env.entries, env.now)
required := relationSchema(t, contenttype.RelationOneToMany, true)
changed := relationSchema(t, contenttype.RelationManyToMany, false)
_, err := update.Update(useCases.UpdateContentTypeCommand{Key: "event", Name: "Event", Fields: required})
if !errors.Is(err, contenttype.ErrSchemaIncompatible) {
t.Errorf("required: %v", err)
}
_, err = update.Update(useCases.UpdateContentTypeCommand{Key: "event", Name: "Event", Fields: changed})
if !errors.Is(err, contenttype.ErrInvalidField) {
t.Errorf("shape: %v", err)
}
})
t.Run("DeleteContentType returns ErrReferenced for a target type", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
remove := useCases.NewDeleteContentTypeUseCase(env.types, env.entries)
if err := remove.Delete("venue"); !errors.Is(err, contenttype.ErrReferenced) {
t.Errorf("venue: %v", err)
}
if err := remove.Delete("event"); err != nil {
t.Errorf("event: %v", err)
}
})
t.Run("A relation lookup failure surfaces as an error", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
env.entries.failGetOn = 1
get := useCases.NewGetEntryUseCase(env.types, env.entries, newMockImageRepo())
env.entries.entries[9] = entry.Entry{ID: 9, ContentTypeID: 2, Data: map[string]any{"title": "A", "link": float64(3)}}
env.entries.failGetOn = 2
if _, err := get.Get("event", 9); !errors.Is(err, errRepositoryFailure) {
t.Errorf("err %v", err)
}
})
}
type relationCountingTypeRepo struct {
*mockContentTypeRepo
getByKeyCalls int
failOnCall int
}
func (r *relationCountingTypeRepo) GetByKey(key string) (contenttype.ContentType, error) {
r.getByKeyCalls++
if r.failOnCall == r.getByKeyCalls {
return contenttype.ContentType{}, errRepositoryFailure
}
return r.mockContentTypeRepo.GetByKey(key)
}
func TestRelationFieldUseCaseEdges(t *testing.T) {
t.Run("types without relation fields never consult the target repositories", func(t *testing.T) {
types := &relationCountingTypeRepo{mockContentTypeRepo: newMockContentTypeRepo()}
entries := newMockEntryRepo()
types.Create(contenttype.ContentType{Key: "plain", Name: "Plain", Fields: []contenttype.Field{{Key: "title", Type: contenttype.TypeText}}})
now := func() time.Time { return time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) }
images := newMockImageRepo()
created, err := useCases.NewCreateEntryUseCase(types, entries, images, now).Create(useCases.CreateEntryCommand{ContentTypeKey: "plain", Data: map[string]any{"title": "A"}, UserID: 1})
if err != nil || len(created.Relations) != 0 {
t.Fatalf("create: %v %v", err, created.Relations)
}
if types.getByKeyCalls != 1 || entries.getCalls != 0 {
t.Errorf("create calls: types %d entries %d", types.getByKeyCalls, entries.getCalls)
}
types.getByKeyCalls = 0
entries.getCalls = 0
if _, err := useCases.NewGetEntryUseCase(types, entries, images).Get("plain", created.ID); err != nil {
t.Fatalf("get: %v", err)
}
if types.getByKeyCalls != 1 || entries.getCalls != 1 {
t.Errorf("get calls: types %d entries %d", types.getByKeyCalls, entries.getCalls)
}
types.getByKeyCalls = 0
entries.getCalls = 0
updated, err := useCases.NewUpdateEntryUseCase(types, entries, images, now).Update(useCases.UpdateEntryCommand{ContentTypeKey: "plain", ID: created.ID, Data: map[string]any{"title": "B"}, UserID: 1})
if err != nil || len(updated.Relations) != 0 {
t.Fatalf("update: %v", err)
}
if types.getByKeyCalls != 1 || entries.getCalls != 1 {
t.Errorf("update calls: types %d entries %d", types.getByKeyCalls, entries.getCalls)
}
types.getByKeyCalls = 0
entries.getCalls = 0
if _, err := useCases.NewListEntriesUseCase(types, entries, images).ListPage(useCases.ListEntriesCommand{ContentTypeKey: "plain", Page: 1, Size: 10, Sort: entry.DefaultSort()}); err != nil {
t.Fatalf("list: %v", err)
}
if types.getByKeyCalls != 1 || entries.getCalls != 0 {
t.Errorf("list calls: types %d entries %d", types.getByKeyCalls, entries.getCalls)
}
})
t.Run("UpdateContentType surfaces a repository failure while checking relation targets", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
types := &relationCountingTypeRepo{mockContentTypeRepo: env.types, failOnCall: 2}
update := useCases.NewUpdateContentTypeUseCase(types, env.entries, env.now)
_, err := update.Update(useCases.UpdateContentTypeCommand{Key: "event", Name: "Event", Fields: relationSchema(t, contenttype.RelationOneToMany, false)})
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("err %v", err)
}
})
t.Run("UpdateContentType allows a field type change while no entry is stored", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
update := useCases.NewUpdateContentTypeUseCase(env.types, env.entries, env.now)
fields := []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeNumber}}
if _, err := update.Update(useCases.UpdateContentTypeCommand{Key: "event", Name: "Event", Fields: fields}); err != nil {
t.Errorf("err %v", err)
}
})
t.Run("DeleteContentType ignores a self reference", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
env.types.Create(contenttype.ContentType{Key: "node", Name: "Node", Fields: []contenttype.Field{{Key: "parent", Type: contenttype.TypeRelation, Relation: &contenttype.Relation{ContentType: "node", Kind: contenttype.RelationOneToMany}}}})
if err := useCases.NewDeleteContentTypeUseCase(env.types, env.entries).Delete("node"); err != nil {
t.Errorf("err %v", err)
}
})
}
func TestRelationFieldIDLimit(t *testing.T) {
many := relationSchema(t, contenttype.RelationManyToMany, false)
build := func(count int) map[string]any {
ids := make([]any, 0, count)
for i := 1; i <= count; i++ {
ids = append(ids, float64(i))
}
return map[string]any{"title": "A", "link": ids}
}
if err := entry.Validate(many, build(contenttype.MaxRelationIDs)); err != nil {
t.Errorf("at limit: %v", err)
}
expectFieldError(t, entry.Validate(many, build(contenttype.MaxRelationIDs+1)), "link", contenttype.RuleType)
}
@@ -0,0 +1,48 @@
package test
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/infrastructure/useCases"
"testing"
"time"
)
func TestRelationFieldListQueryClampsPaging(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
env.entries.entries[4] = entry.Entry{ID: 4, ContentTypeID: 1, Data: map[string]any{"title": "Arena"}}
list := useCases.NewListEntriesUseCase(env.types, env.entries, newMockImageRepo())
cases := []struct {
name string
page int
size int
items int
}{
{"page below one acts as page one", 0, 10, 2},
{"negative page acts as page one", -3, 1, 1},
{"negative size yields an empty page", 1, -1, 0},
}
for _, example := range cases {
t.Run(example.name, func(t *testing.T) {
page, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "venue", Page: example.page, Size: example.size, Sort: entry.DefaultSort(), Query: "a"})
if err != nil {
t.Fatalf("err %v", err)
}
if len(page.Items) != example.items || page.Total != 2 {
t.Errorf("items %d total %d", len(page.Items), page.Total)
}
})
}
}
func TestRelationFieldTargetWithoutFieldsIsMissing(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
venue, _ := env.types.GetByKey("venue")
venue.Fields = nil
env.types.types[venue.ID] = venue
create := useCases.NewCreateEntryUseCase(env.types, env.entries, newMockImageRepo(), func() time.Time { return time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) })
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(3)}, UserID: 1})
expectFieldError(t, err, "link", contenttype.RuleType)
}
+4
View File
@@ -29,6 +29,7 @@ const props = defineProps<{
field: BuilderField field: BuilderField
errors: FieldErrors errors: FieldErrors
compact: boolean compact: boolean
ownerName?: string
}>() }>()
const uid = () => props.field.uid const uid = () => props.field.uid
@@ -40,12 +41,15 @@ const uid = () => props.field.uid
:errors="errors" :errors="errors"
:disabled="saving" :disabled="saving"
:compact="compact" :compact="compact"
:owner-name="ownerName"
:focus-token="focusToken" :focus-token="focusToken"
@label="actions.setLabel(uid(), $event)" @label="actions.setLabel(uid(), $event)"
@key="actions.setFieldKey(uid(), $event)" @key="actions.setFieldKey(uid(), $event)"
@unlock="actions.unlockFieldKey(uid())" @unlock="actions.unlockFieldKey(uid())"
@reset="actions.resetFieldKey(uid())" @reset="actions.resetFieldKey(uid())"
@type="actions.setFieldType(uid(), $event)" @type="actions.setFieldType(uid(), $event)"
@relation-type="actions.patchField(uid(), { relationType: $event }, 'relation')"
@relation-kind="actions.patchField(uid(), { relationKind: $event })"
@required="actions.patchField(uid(), { required: $event })" @required="actions.patchField(uid(), { required: $event })"
@unique="actions.patchField(uid(), { unique: $event })" @unique="actions.patchField(uid(), { unique: $event })"
@limit="(which, value) => actions.setLimit(uid(), which, value)" @limit="(which, value) => actions.setLimit(uid(), which, value)"
+15
View File
@@ -11,6 +11,7 @@ const props = defineProps<{
name: string name: string
keyValue: string keyValue: string
total: number total: number
referrer: string
pending: boolean pending: boolean
offline: boolean offline: boolean
}>() }>()
@@ -43,6 +44,20 @@ const notice = computed(() => {
), ),
} }
} }
if (props.stage === 'referenced') {
return {
title: t("You can't delete {0} yet", [props.name]),
description: props.referrer
? t('{0} has a field that points to {1}. Remove that field first.', [
props.referrer,
props.name,
])
: t(
'Another content type has a field that points to {0}. Remove that field first.',
[props.name],
),
}
}
if (props.stage === 'failed') { if (props.stage === 'failed') {
return { return {
title: t("Couldn't delete the content type"), title: t("Couldn't delete the content type"),
+27 -1
View File
@@ -11,7 +11,9 @@ import {
thumbnailColumn, thumbnailColumn,
} from '@/lib/entryCells' } from '@/lib/entryCells'
import { t } from '@/i18n' import { t } from '@/i18n'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { mediaOf } from '@/lib/mediaField' import { mediaOf } from '@/lib/mediaField'
import { summariesOf } from '@/lib/relationField'
import { entryPath } from '@/lib/routePaths' import { entryPath } from '@/lib/routePaths'
import { computed } from 'vue' import { computed } from 'vue'
@@ -26,11 +28,21 @@ const props = defineProps<{
const shown = computed(() => cardFields(props.fields)) const shown = computed(() => cardFields(props.fields))
const lead = computed(() => thumbnailColumn(props.columns)) const lead = computed(() => thumbnailColumn(props.columns))
const relationColumns = computed(() =>
props.columns.filter(
(field) => field !== shown.value.first && fieldTypeSpec(field.type).cell === 'relation',
),
)
function textOf(field: BuiltinField | undefined, entry: ContentEntry) { function textOf(field: BuiltinField | undefined, entry: ContentEntry) {
return field return field
? plainCellText( ? plainCellText(
describeCell(field, entry.data[field.key], mediaOf(entry, field.key) ?? undefined), describeCell(
field,
entry.data[field.key],
mediaOf(entry, field.key) ?? undefined,
summariesOf(entry, field.key),
),
) )
: '' : ''
} }
@@ -80,6 +92,20 @@ function nameOf(entry: ContentEntry) {
<span class="truncate text-md/5.25 font-medium text-highlighted">{{ <span class="truncate text-md/5.25 font-medium text-highlighted">{{
titleOf(entry) titleOf(entry)
}}</span> }}</span>
<span
v-for="field in relationColumns"
:key="field.key"
data-testid="entry-card-relation"
class="flex flex-wrap items-center gap-1.5"
>
<span class="text-xs text-muted">{{ field.label }}</span>
<FieldCell
:field="field"
:value="entry.data[field.key]"
:relations="summariesOf(entry, field.key)"
compact
/>
</span>
<span class="flex items-center gap-2 text-xs text-muted"> <span class="flex items-center gap-2 text-xs text-muted">
<template v-if="shown.second && !secondIsChoice"> <template v-if="shown.second && !secondIsChoice">
<span class="shrink-0">{{ textOf(shown.second, entry) }}</span> <span class="shrink-0">{{ textOf(shown.second, entry) }}</span>
+2
View File
@@ -111,6 +111,8 @@ function backToList() {
:fields="page.fields.value" :fields="page.fields.value"
:values="page.form.values.value" :values="page.form.values.value"
:images="page.form.images.value" :images="page.form.images.value"
:relations="page.form.relations.value"
:type-name="page.typeName.value"
:issues="page.form.issues.value" :issues="page.form.issues.value"
:first-issue="page.form.firstIssue.value" :first-issue="page.form.firstIssue.value"
:focus-token="page.focusToken.value" :focus-token="page.focusToken.value"
+27 -1
View File
@@ -4,10 +4,12 @@ import BuilderBadge from '@/components/BuilderBadge.vue'
import DirectoryField from '@/components/DirectoryField.vue' import DirectoryField from '@/components/DirectoryField.vue'
import EntryControl from '@/components/EntryControl.vue' import EntryControl from '@/components/EntryControl.vue'
import MediaFieldControl from '@/components/MediaFieldControl.vue' import MediaFieldControl from '@/components/MediaFieldControl.vue'
import RelationField from '@/components/RelationField.vue'
import type { BuiltinField } from '@/lib/contentTypes' import type { BuiltinField } from '@/lib/contentTypes'
import type { FieldIssue } from '@/lib/entryForm' import type { FieldIssue } from '@/lib/entryForm'
import { fieldTypeSpec } from '@/lib/fieldTypes' import { fieldTypeSpec } from '@/lib/fieldTypes'
import { isMediaId, type MediaSummary } from '@/lib/mediaField' import { isMediaId, type MediaSummary } from '@/lib/mediaField'
import { holdsManyRelations, type RelationSummary } from '@/lib/relationField'
const props = defineProps<{ const props = defineProps<{
field: BuiltinField field: BuiltinField
@@ -16,14 +18,24 @@ const props = defineProps<{
isDefault: boolean isDefault: boolean
disabled: boolean disabled: boolean
image?: MediaSummary | null image?: MediaSummary | null
relations?: RelationSummary[]
typeName: string
}>() }>()
const emit = defineEmits<{ update: [key: string, value: unknown, image?: MediaSummary | null] }>() const emit = defineEmits<{
update: [
key: string,
value: unknown,
image?: MediaSummary | null,
relations?: RelationSummary[],
]
}>()
const editor = computed(() => fieldTypeSpec(props.field.type).editor) const editor = computed(() => fieldTypeSpec(props.field.type).editor)
const counter = computed(() => editor.value.counter(props.field, props.value)) const counter = computed(() => editor.value.counter(props.field, props.value))
const help = computed(() => editor.value.help(props.field)) const help = computed(() => editor.value.help(props.field))
const isMedia = computed(() => editor.value.control === 'media') const isMedia = computed(() => editor.value.control === 'media')
const isRelation = computed(() => editor.value.control === 'relation')
const mediaId = computed(() => (isMediaId(props.value) ? props.value : null)) const mediaId = computed(() => (isMediaId(props.value) ? props.value : null))
</script> </script>
@@ -40,6 +52,20 @@ const mediaId = computed(() => (isMediaId(props.value) ? props.value : null))
:error="issue?.message" :error="issue?.message"
@change="(id, picked) => emit('update', field.key, id, picked)" @change="(id, picked) => emit('update', field.key, id, picked)"
/> />
<RelationField
v-else-if="isRelation"
:label="field.label"
:name="field.key"
:required="field.required"
:many="holdsManyRelations(field)"
:target="field.relation?.content_type ?? ''"
:type-name="typeName"
:value="value"
:summaries="relations"
:disabled="disabled"
:error="issue?.message"
@change="(next, summaries) => emit('update', field.key, next, undefined, summaries)"
/>
<DirectoryField <DirectoryField
v-else v-else
:label="field.label" :label="field.label"
+14 -2
View File
@@ -6,6 +6,7 @@ import EntryField from '@/components/EntryField.vue'
import type { BuiltinField } from '@/lib/contentTypes' import type { BuiltinField } from '@/lib/contentTypes'
import type { EntryIssues, EntryValues } from '@/lib/entryForm' import type { EntryIssues, EntryValues } from '@/lib/entryForm'
import type { MediaMap, MediaSummary } from '@/lib/mediaField' import type { MediaMap, MediaSummary } from '@/lib/mediaField'
import type { RelationSummaries, RelationSummary } from '@/lib/relationField'
import { groupFields } from '@/lib/entryLayout' import { groupFields } from '@/lib/entryLayout'
const FOCUSABLE = const FOCUSABLE =
@@ -15,6 +16,8 @@ const props = defineProps<{
fields: BuiltinField[] fields: BuiltinField[]
values: EntryValues values: EntryValues
images: MediaMap images: MediaMap
relations: RelationSummaries
typeName: string
issues: EntryIssues issues: EntryIssues
firstIssue: string | null firstIssue: string | null
focusToken: number focusToken: number
@@ -28,7 +31,12 @@ const props = defineProps<{
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
update: [key: string, value: unknown, image?: MediaSummary | null] update: [
key: string,
value: unknown,
image?: MediaSummary | null,
relations?: RelationSummary[],
]
submit: [] submit: []
}>() }>()
@@ -73,10 +81,14 @@ watch(() => props.focusToken, focusFirstIssue)
:field="field" :field="field"
:value="values[field.key]" :value="values[field.key]"
:image="images[field.key] ?? null" :image="images[field.key] ?? null"
:relations="relations[field.key] ?? []"
:type-name="typeName"
:issue="issues[field.key]" :issue="issues[field.key]"
:is-default="isDefault(field)" :is-default="isDefault(field)"
:disabled="locked" :disabled="locked"
@update="(key, value, image) => emit('update', key, value, image)" @update="
(key, value, image, summaries) => emit('update', key, value, image, summaries)
"
/> />
</div> </div>
+7 -2
View File
@@ -20,12 +20,17 @@ const props = defineProps<{
defineEmits<{ retry: []; reload: [] }>() defineEmits<{ retry: []; reload: [] }>()
const NAMED_CONTROLS = ['media', 'relation']
const uniqueField = computed(() => const uniqueField = computed(() =>
props.fields.find((field) => props.issues[field.key]?.rule === 'unique'), props.fields.find(
(field) =>
props.issues[field.key]?.rule === 'unique' && fieldTypeSpec(field.type).supportsUnique,
),
) )
const changedLine = computed(() => { const changedLine = computed(() => {
const changed = props.fields.find((field) => field.key === props.schemaField) const changed = props.fields.find((field) => field.key === props.schemaField)
return changed && fieldTypeSpec(changed.type).editor.control === 'media' return changed && NAMED_CONTROLS.includes(fieldTypeSpec(changed.type).editor.control)
? mediaChangedLine(changed.label) ? mediaChangedLine(changed.label)
: schemaChangedLine(props.schemaField) : schemaChangedLine(props.schemaField)
}) })
+9 -1
View File
@@ -9,11 +9,13 @@ import { EMPTY_TEXT, formatEntryDate } from '@/lib/entryCells'
import { ENTRY_SORT_FIELDS, sortParts } from '@/lib/entryListQuery' import { ENTRY_SORT_FIELDS, sortParts } from '@/lib/entryListQuery'
import { fieldTypeSpec } from '@/lib/fieldTypes' import { fieldTypeSpec } from '@/lib/fieldTypes'
import { mediaOf } from '@/lib/mediaField' import { mediaOf } from '@/lib/mediaField'
import { summariesOf } from '@/lib/relationField'
import { entryPath } from '@/lib/routePaths' import { entryPath } from '@/lib/routePaths'
const ID_WIDTH = 'w-16.5' const ID_WIDTH = 'w-16.5'
const NARROW_WIDTH = 'w-23' const NARROW_WIDTH = 'w-23'
const FIELD_WIDTH = 'w-33' const FIELD_WIDTH = 'w-33'
const WIDE_WIDTH = 'w-50'
const CREATED_WIDTH = 'w-30.5' const CREATED_WIDTH = 'w-30.5'
const UPDATED_WIDTH = 'w-40.5' const UPDATED_WIDTH = 'w-40.5'
const UPDATED_BY_WIDTH = 'w-37' const UPDATED_BY_WIDTH = 'w-37'
@@ -37,7 +39,11 @@ function widthOf(field: BuiltinField, index: number) {
if (index === 0) { if (index === 0) {
return '' return ''
} }
return fieldTypeSpec(field.type).narrowColumn ? NARROW_WIDTH : FIELD_WIDTH const spec = fieldTypeSpec(field.type)
if (spec.wideColumn) {
return WIDE_WIDTH
}
return spec.narrowColumn ? NARROW_WIDTH : FIELD_WIDTH
} }
function column(id: string, header: string, width: string, edge = 'px-1.5') { function column(id: string, header: string, width: string, edge = 'px-1.5') {
@@ -152,6 +158,7 @@ watchPostEffect(() => {
:field="field" :field="field"
:value="row.original.data[field.key]" :value="row.original.data[field.key]"
:image="mediaOf(row.original, field.key)" :image="mediaOf(row.original, field.key)"
:relations="summariesOf(row.original, field.key)"
class="min-w-0" class="min-w-0"
/> />
</RouterLink> </RouterLink>
@@ -160,6 +167,7 @@ watchPostEffect(() => {
:field="field" :field="field"
:value="row.original.data[field.key]" :value="row.original.data[field.key]"
:image="mediaOf(row.original, field.key)" :image="mediaOf(row.original, field.key)"
:relations="summariesOf(row.original, field.key)"
/> />
</template> </template>
+21 -1
View File
@@ -2,18 +2,23 @@
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
import RelationChip from '@/components/RelationChip.vue'
import type { BuiltinField } from '@/lib/contentTypes' import type { BuiltinField } from '@/lib/contentTypes'
import { EMPTY_TEXT, describeCell, isEmptyCell } from '@/lib/entryCells' import { EMPTY_TEXT, describeCell, isEmptyCell } from '@/lib/entryCells'
import { isMissingImage, type MediaSummary } from '@/lib/mediaField' import { isMissingImage, type MediaSummary } from '@/lib/mediaField'
import type { RelationSummary } from '@/lib/relationField'
const props = defineProps<{ const props = defineProps<{
field: BuiltinField field: BuiltinField
value: unknown value: unknown
image?: MediaSummary | null image?: MediaSummary | null
relations?: RelationSummary[]
compact?: boolean compact?: boolean
}>() }>()
const cell = computed(() => describeCell(props.field, props.value, props.image ?? undefined)) const cell = computed(() =>
describeCell(props.field, props.value, props.image ?? undefined, props.relations),
)
const shownImage = computed(() => { const shownImage = computed(() => {
const image = cell.value.image const image = cell.value.image
return image && !isMissingImage(image) ? image : null return image && !isMissingImage(image) ? image : null
@@ -61,6 +66,21 @@ const TIP_UI = {
<UIcon name="i-lucide-unlink" :class="compact ? 'size-3' : 'size-3.5'" /> <UIcon name="i-lucide-unlink" :class="compact ? 'size-3' : 'size-3.5'" />
</span> </span>
</UTooltip> </UTooltip>
<span
v-else-if="cell.kind === 'relation'"
data-testid="relation-cell"
:title="cell.text"
class="flex min-w-0 items-center gap-1"
:class="compact ? 'flex-wrap' : 'overflow-hidden'"
>
<RelationChip
v-for="chip in cell.chips"
:key="chip.id"
:label="chip.label"
:missing="chip.missing"
/>
<span v-if="cell.more" class="text-xs text-muted">+{{ cell.more }}</span>
</span>
<span v-else-if="cell.kind === 'boolean'" class="flex items-center gap-1.5"> <span v-else-if="cell.kind === 'boolean'" class="flex items-center gap-1.5">
<UIcon <UIcon
:name="cell.yes ? 'i-lucide-check' : 'i-lucide-minus'" :name="cell.yes ? 'i-lucide-check' : 'i-lucide-minus'"
+20
View File
@@ -6,6 +6,7 @@ import AppSwitch from '@/components/AppSwitch.vue'
import ChoiceList from '@/components/ChoiceList.vue' import ChoiceList from '@/components/ChoiceList.vue'
import DirectoryField from '@/components/DirectoryField.vue' import DirectoryField from '@/components/DirectoryField.vue'
import FieldTypePicker from '@/components/FieldTypePicker.vue' import FieldTypePicker from '@/components/FieldTypePicker.vue'
import RelationPicker from '@/components/RelationPicker.vue'
import SlugField from '@/components/SlugField.vue' import SlugField from '@/components/SlugField.vue'
import { fieldTypeSpec } from '@/lib/fieldTypes' import { fieldTypeSpec } from '@/lib/fieldTypes'
import { import {
@@ -18,6 +19,7 @@ import {
type BuilderField, type BuilderField,
type FieldType, type FieldType,
} from '@/lib/schemaBuilderModel' } from '@/lib/schemaBuilderModel'
import type { RelationKind } from '@/lib/relationField'
import type { FieldErrors } from '@/lib/schemaBuilderValidation' import type { FieldErrors } from '@/lib/schemaBuilderValidation'
const NO_DEFAULT = 'none' const NO_DEFAULT = 'none'
@@ -29,6 +31,7 @@ const props = defineProps<{
disabled: boolean disabled: boolean
compact: boolean compact: boolean
focusToken: number focusToken: number
ownerName?: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -37,6 +40,8 @@ const emit = defineEmits<{
unlock: [] unlock: []
reset: [] reset: []
type: [type: FieldType] type: [type: FieldType]
relationType: [key: string]
relationKind: [kind: RelationKind]
required: [value: boolean] required: [value: boolean]
unique: [value: boolean] unique: [value: boolean]
limit: [which: 'min' | 'max', value: string] limit: [which: 'min' | 'max', value: string]
@@ -58,6 +63,7 @@ const showUnique = computed(() => canUnique.value || spec.value.showsUnsupported
const showRuleGrid = computed( const showRuleGrid = computed(
() => hasLimits.value || spec.value.supportsDefault || spec.value.showsUnsupportedOptions, () => hasLimits.value || spec.value.supportsDefault || spec.value.showsUnsupportedOptions,
) )
const holdsRelation = computed(() => spec.value.holdsRelation)
const hint = computed(() => spec.value.builderHint?.() ?? '') const hint = computed(() => spec.value.builderHint?.() ?? '')
const keyMode = computed(() => keyDisplayMode(props.field.keyMode, props.field.key)) const keyMode = computed(() => keyDisplayMode(props.field.keyMode, props.field.key))
const gridClass = computed(() => (props.compact ? 'grid-cols-1' : 'md:grid-cols-2')) const gridClass = computed(() => (props.compact ? 'grid-cols-1' : 'md:grid-cols-2'))
@@ -193,6 +199,20 @@ watch(
@update:model-value="emit('type', $event)" @update:model-value="emit('type', $event)"
/> />
<RelationPicker
v-if="holdsRelation"
:label="field.label"
:owner-name="ownerName ?? ''"
:type-key="field.relationType"
:kind="field.relationKind"
:locked="field.saved"
:disabled="disabled"
:compact="compact"
:error="errors.relation"
@type="emit('relationType', $event)"
@kind="emit('relationKind', $event)"
/>
<div class="flex min-w-0 flex-col border-t border-default"> <div class="flex min-w-0 flex-col border-t border-default">
<AppSwitch <AppSwitch
:model-value="field.required" :model-value="field.required"
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
withDefaults(defineProps<{ label: string; missing?: boolean; tall?: boolean }>(), {
missing: false,
tall: false,
})
</script>
<template>
<UBadge
color="neutral"
variant="soft"
:label="label"
data-testid="relation-chip"
:data-missing="missing ? 'true' : undefined"
class="max-w-full ring-0"
:class="
missing
? 'border border-dashed border-accented bg-transparent text-toned'
: 'bg-(--rc-sand) text-(--rc-on-sand)'
"
:ui="{
base: [
tall ? 'h-5.5 px-2' : 'h-5 px-1.5',
'rounded-none py-0 text-xs font-medium whitespace-nowrap',
],
label: 'truncate',
}"
/>
</template>
+461
View File
@@ -0,0 +1,461 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onMounted, ref, useId, watch } from 'vue'
import DirectoryField from '@/components/DirectoryField.vue'
import { useRelationSearch } from '@/composables/useRelationSearch'
import { relationLabel } from '@/lib/entryCells'
import {
RELATION_MAX,
isMissingRelation,
relationIdsOf,
type RelationSummary,
} from '@/lib/relationField'
import { relationRowOf } from '@/lib/relationRows'
import { useContentTypesStore } from '@/stores/contentTypes'
interface ChipItem {
label: string
value: number
missing: boolean
summary: RelationSummary
}
interface RowItem {
label: string
description: string
value: number
summary: RelationSummary
}
interface HighlightPayload {
ref?: HTMLElement
}
const SKELETON_ROWS = 3
const LISTBOX = '[role="listbox"]'
const OWNER_ATTRIBUTE = 'data-relation-owner'
const VOWEL = /^[aeiou]/
const props = withDefaults(
defineProps<{
label: string
name?: string
required?: boolean
many?: boolean
target: string
typeName: string
value: unknown
summaries?: RelationSummary[]
disabled?: boolean
error?: string
}>(),
{
name: undefined,
required: false,
many: false,
summaries: () => [],
disabled: false,
error: '',
},
)
const emit = defineEmits<{
change: [value: number | number[] | null, summaries: RelationSummary[]]
}>()
const id = useId()
const labelId = `${id}-label`
const helpId = `${id}-help`
const errorId = `${id}-error`
const store = useContentTypesStore()
const search = useRelationSearch(() => props.target)
const open = ref(false)
const searchTerm = ref('')
const announcement = ref('')
const activeId = ref<string | undefined>(undefined)
const listboxId = ref<string | undefined>(undefined)
const ids = computed(() => relationIdsOf(props.value))
const chips = computed<ChipItem[]>(() =>
ids.value.map((chipId) => {
const summary = props.summaries.find((candidate) => candidate.id === chipId) ?? {
id: chipId,
missing: true as const,
}
return {
label: relationLabel(summary),
value: chipId,
missing: isMissingRelation(summary),
summary,
}
}),
)
const targetType = computed(() => store.items.find((type) => type.key === props.target))
const targetName = computed(() => targetType.value?.name ?? props.target)
const atCap = computed(() => props.many && ids.value.length >= RELATION_MAX)
const rows = computed<RowItem[]>(() =>
search.entries.value
.map((entry) => relationRowOf(entry, props.target, targetType.value?.fields ?? []))
.filter((row) => !ids.value.includes(row.id))
.map((row) => ({
label: row.title,
description: row.detail,
value: row.id,
summary: row.summary,
})),
)
const menuItems = computed(() => (atCap.value || search.showSkeleton.value ? [] : rows.value))
const hasMissing = computed(() => chips.value.some((chip) => chip.missing))
const ownerWord = computed(() => props.typeName.replace(/s$/i, '').toLowerCase())
const help = computed(() => {
if (hasMissing.value) {
return props.required
? t('{0} is required. Choose another to save.', [props.label])
: t('You can still save. The entry keeps the link until you change it.')
}
if (!props.many) {
return ''
}
const plural = props.label.toLowerCase()
return VOWEL.test(ownerWord.value)
? t('An {0} can have many {1}.', [ownerWord.value, plural])
: t('A {0} can have many {1}.', [ownerWord.value, plural])
})
const describedBy = computed(() => (props.error ? `${errorId} ${helpId}` : helpId))
const emptyKind = computed(() => {
if (atCap.value) {
return 'cap'
}
if (search.showSkeleton.value) {
return 'loading'
}
const status = search.status.value
if (status === 'limited' || status === 'failed') {
return status
}
if (status !== 'ready') {
return 'none'
}
if (search.total.value > 0) {
return 'chosen'
}
return search.query.value === '' ? 'empty' : 'noMatch'
})
const baseUi = computed(() =>
props.error
? 'border-error shadow-[inset_0_0_0_1px_var(--ui-error)] has-focus-visible:border-error'
: 'border-accented has-focus-visible:border-primary has-focus-visible:shadow-[inset_0_0_0_1px_var(--ui-primary)]',
)
const menuUi = computed(() => ({
base: `min-h-8 gap-1.5 rounded-none border bg-default px-2 py-1 text-sm ring-0 outline-0 has-focus-visible:ring-0 has-focus-visible:outline-0 max-md:min-h-11 max-md:text-lg ${baseUi.value} ${props.many ? '' : 'pe-8'}`,
tagsItem:
'h-6 gap-1.5 rounded-none bg-(--rc-sand) py-0 ps-2 pe-1 text-sm font-medium text-(--rc-on-sand) ring-0 has-data-[missing=true]:border has-data-[missing=true]:border-dashed has-data-[missing=true]:border-accented has-data-[missing=true]:bg-transparent has-data-[missing=true]:font-normal has-data-[missing=true]:text-toned max-md:h-8',
tagsItemText: 'truncate',
tagsItemDelete:
'relative rounded-none text-inherit hover:bg-transparent hover:text-inherit max-md:before:absolute max-md:before:-inset-1.5',
tagsInput: 'min-w-20 flex-1 placeholder:text-muted',
trailing: 'pe-2.5',
trailingIcon: 'size-3.5 text-muted',
content: 'rounded-none border border-default p-1 shadow-(--shadow-popover) ring-0',
empty: 'p-2 text-left text-sm text-muted',
group: 'p-0',
item: 'min-h-10 flex-col justify-center gap-0 rounded-none px-2 py-1 before:inset-0 before:rounded-none data-highlighted:not-data-disabled:before:bg-elevated max-md:min-h-12',
itemLabel: 'font-medium text-highlighted',
itemDescription: 'text-xs/5 text-muted',
}))
function removeName(element: HTMLElement, binding: { value: string }) {
const button = element.closest('button')
button?.removeAttribute('aria-labelledby')
button?.setAttribute('aria-label', binding.value)
}
const vRemoveName = { mounted: removeName, updated: removeName }
function removeLabel(item: ChipItem) {
return t('Remove {0} from {1}', [item.label, props.label])
}
function announce(message: string) {
announcement.value = ''
nextTick(() => {
announcement.value = message
})
}
function summaryOf(chipId: number, picked: RowItem[]) {
return (
chips.value.find((chip) => chip.value === chipId)?.summary ??
picked.find((row) => row.value === chipId)?.summary
)
}
function changeSingle(picked: RowItem[]) {
const row = picked[picked.length - 1]
if (!row) {
emit('change', null, [])
announce(t('{0} cleared.', [props.label]))
return
}
emit('change', row.value, [row.summary])
announce(t('{0} set to {1}.', [props.label, row.label]))
open.value = false
}
function changeMany(next: Array<ChipItem | RowItem>, picked: RowItem[]) {
const nextIds = next.map((item) => item.value)
emit(
'change',
nextIds,
nextIds.flatMap((nextId) => summaryOf(nextId, picked) ?? []),
)
const removed = chips.value.find((chip) => !nextIds.includes(chip.value))
if (removed) {
announce(t('{0} removed from {1}.', [removed.label, props.label]))
} else if (picked[0]) {
announce(t('{0} added to {1}.', [picked[0].label, props.label]))
}
}
function onUpdate(next: Array<ChipItem | RowItem>) {
if (props.disabled) {
return
}
const picked = next.filter(
(item): item is RowItem => !ids.value.includes(item.value) && 'description' in item,
)
if (props.many) {
changeMany(next, picked)
} else {
changeSingle(picked)
}
}
function onBackspace(event: KeyboardEvent) {
const input = event.target as HTMLInputElement
const lastChip = chips.value.length - 1
if (
event.key !== 'Backspace' ||
input.getAttribute('role') !== 'combobox' ||
input.value !== '' ||
lastChip < 0 ||
props.disabled
) {
return
}
event.preventDefault()
event.stopPropagation()
onUpdate(chips.value.slice(0, lastChip))
}
function onHighlight(payload: HighlightPayload | undefined) {
activeId.value = payload?.ref?.id || undefined
}
function clearSearch() {
searchTerm.value = ''
}
async function trackListbox() {
await nextTick()
const owned = `[${OWNER_ATTRIBUTE}="${id}"]`
listboxId.value =
document.querySelector<HTMLElement>(`${owned}${LISTBOX}, ${owned} ${LISTBOX}`)?.id ||
undefined
}
function announceResults() {
const total = search.total.value
announce(total > 0 ? t('{0} results', [total]) : t('No results'))
}
watch(searchTerm, (term) => {
if (open.value) {
search.search(term)
}
})
watch(open, (isOpen) => {
if (isOpen) {
search.search(searchTerm.value)
trackListbox()
} else {
activeId.value = undefined
listboxId.value = undefined
}
})
watch(
() => search.status.value,
(status) => {
if (open.value && status === 'ready') {
announceResults()
}
},
)
onMounted(() => store.load())
</script>
<template>
<div class="min-w-0">
<DirectoryField
:label="label"
:required="required"
:name="name"
:label-id="labelId"
:error="error"
:error-id="errorId"
>
<div
role="group"
:aria-labelledby="labelId"
data-testid="relation-control"
@keydown.capture="onBackspace"
>
<UInputMenu
:model-value="chips"
v-model:open="open"
v-model:search-term="searchTerm"
multiple
ignore-filter
open-on-focus
open-on-click
by="value"
:items="menuItems"
:placeholder="t('Search to add')"
:disabled="disabled"
:color="error ? 'error' : undefined"
:trailing="!many"
trailing-icon="i-lucide-search"
:aria-controls="listboxId"
:aria-required="required ? 'true' : undefined"
:aria-activedescendant="activeId"
:aria-describedby="describedBy"
class="w-full"
:content="{ sideOffset: 6, [OWNER_ATTRIBUTE]: id }"
:ui="menuUi"
@update:model-value="onUpdate"
@highlight="onHighlight"
>
<template #tags-item-text="{ item }">
<span :data-missing="item.missing ? 'true' : undefined">{{
item.label
}}</span>
</template>
<template #tags-item-delete="{ item }">
<UIcon
v-remove-name="removeLabel(item)"
name="i-lucide-x"
class="size-3.5"
/>
</template>
<template #item="{ item }">
<span class="font-medium text-highlighted">{{ item.label }}</span>
<span v-if="item.description" class="text-xs/5 text-muted">{{
item.description
}}</span>
</template>
<template #empty>
<div data-testid="relation-empty" class="flex flex-col items-start gap-2">
<template v-if="emptyKind === 'loading'">
<span class="sr-only" role="status" aria-live="polite">{{
t('Searching…')
}}</span>
<USkeleton
v-for="row in SKELETON_ROWS"
:key="row"
data-testid="relation-skeleton"
class="h-10 w-full rounded-none bg-accented max-md:h-12"
/>
</template>
<template v-else-if="emptyKind === 'failed'">
<span class="font-medium text-error">{{
t("Couldn't load {0}", [targetName])
}}</span>
<span>{{ t('Check your connection and try again.') }}</span>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-8 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:h-11"
@click="search.reload()"
>
{{ t('Try again') }}
</UButton>
</template>
<template v-else-if="emptyKind === 'limited'">
<span class="font-medium text-warning">{{
t('Too many requests')
}}</span>
<span>{{
search.limit.active.value
? t('Try again in {0}', [search.limit.formatted.value])
: t('Wait a moment before trying again.')
}}</span>
<UButton
v-if="!search.limit.active.value"
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-8 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:h-11"
@click="search.reload()"
>
{{ t('Try again') }}
</UButton>
</template>
<span v-else-if="emptyKind === 'cap'">{{
t('You can add up to {0}.', [RELATION_MAX])
}}</span>
<span v-else-if="emptyKind === 'empty'">{{
t('No {0} yet. Add one in Content, then come back.', [targetName])
}}</span>
<template v-else-if="emptyKind === 'noMatch'">
<span>{{
t('No {0} match "{1}"', [targetName, search.query.value])
}}</span>
<UButton
color="neutral"
variant="ghost"
class="h-8 px-2 text-xs text-highlighted max-md:h-11"
@click="clearSearch"
>
{{ t('Clear search') }}
</UButton>
</template>
<span v-else-if="emptyKind === 'chosen'">{{
t('No other {0} to add.', [targetName])
}}</span>
</div>
</template>
<template #content-bottom>
<UButton
v-if="search.hasMore.value && !atCap"
color="neutral"
variant="ghost"
:loading="search.loadingMore.value"
class="h-8 w-full justify-center text-xs text-highlighted max-md:h-11"
@click="search.more()"
>
{{ t('Load more') }}
</UButton>
</template>
</UInputMenu>
</div>
<p
v-if="help"
:id="helpId"
data-testid="relation-help"
class="mt-1.5 text-xs/4.5 text-pretty text-muted"
:class="error ? 'sr-only' : ''"
>
{{ help }}
</p>
<span class="sr-only" role="status" aria-live="polite" data-testid="relation-live">{{
announcement
}}</span>
</DirectoryField>
</div>
</template>
+257
View File
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted } from 'vue'
import DirectoryField from '@/components/DirectoryField.vue'
import RelationChip from '@/components/RelationChip.vue'
import { RELATION_KINDS, isRelationKind, type RelationKind } from '@/lib/relationField'
import {
inverseNoun,
inverseSide,
kindFromInverseSide,
kindFromOwnerSide,
kindSentence,
ownerNoun,
ownerSide,
relationNames,
relationSample,
type RelationSide,
} from '@/lib/relationSentences'
import { useContentTypesStore } from '@/stores/contentTypes'
const DIAGRAMS: Record<RelationKind, readonly [number, number]> = {
one_to_one: [1, 1],
one_to_many: [3, 1],
many_to_many: [2, 2],
}
const props = defineProps<{
label: string
ownerName: string
typeKey: string
kind: RelationKind
locked: boolean
disabled: boolean
compact: boolean
error?: string
}>()
const emit = defineEmits<{ type: [key: string]; kind: [kind: RelationKind] }>()
const store = useContentTypesStore()
const typeItems = computed(() =>
store.items.map((type) => ({ label: type.name, value: type.key, hint: type.key })),
)
const related = computed(() => store.items.find((type) => type.key === props.typeKey))
const names = computed(() => relationNames(props.ownerName, related.value?.name ?? '', props.label))
const frozen = computed(() => props.locked || props.disabled)
const kindItems = computed(() =>
RELATION_KINDS.map((kind) => ({
value: kind,
label: kindTitle(kind),
sentence: kindSentence(kind, names.value),
})),
)
const sideItems = computed(() => [
{ label: t('one'), value: 'one' },
{ label: t('many'), value: 'many' },
])
const sample = computed(() => relationSample(props.kind))
function kindTitle(kind: RelationKind) {
if (kind === 'one_to_one') {
return t('One to one')
}
return kind === 'one_to_many' ? t('One to many') : t('Many to many')
}
function squares(count: number) {
return Array.from({ length: count }, (_, index) => index)
}
function onKind(value: unknown) {
if (isRelationKind(value)) {
emit('kind', value)
}
}
function onOwnerSide(value: unknown) {
emit('kind', kindFromOwnerSide(value as RelationSide, props.kind))
}
function onInverseSide(value: unknown) {
emit('kind', kindFromInverseSide(value as RelationSide, props.kind))
}
const SIDE_UI = {
base: 'mx-1 h-7 w-auto gap-1.5 rounded-none border border-accented bg-default py-0 ps-2 pe-7 text-sm font-medium text-highlighted ring-0',
trailing: 'pe-2',
trailingIcon: 'size-3 text-muted',
value: 'truncate',
}
const CARD_UI = {
root: 'w-full',
legend: 'mb-1.5 w-full font-medium text-highlighted',
item: 'group min-h-0 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)',
wrapper: 'ms-0 w-full',
label: 'w-full text-base font-medium text-inherit',
}
onMounted(() => store.load())
</script>
<template>
<div class="flex min-w-0 flex-col gap-3.5" data-testid="relation-picker">
<p
v-if="locked"
data-testid="relation-locked-note"
class="flex items-center gap-1.5 text-xs/4.5 text-muted"
>
<UIcon name="i-lucide-lock" class="size-3 shrink-0" />
{{ t("The related type and the connection can't change after saving.") }}
</p>
<DirectoryField
:label="t('Related content type')"
name="relation-type"
:error="error"
:help="t('Choose the content type this field points to.')"
>
<USelect
:model-value="typeKey === '' ? undefined : typeKey"
:items="typeItems"
:placeholder="t('Choose a content type')"
:disabled="frozen"
:color="error ? 'error' : undefined"
class="w-full"
:ui="{
base: 'h-11 text-lg disabled:bg-elevated disabled:opacity-100 md:h-8 md:text-sm',
content: 'rounded-none shadow-(--shadow-popover)',
}"
@update:model-value="emit('type', String($event))"
>
<template #item-label="{ item }">
<span class="flex min-w-0 items-baseline gap-2">
<span class="truncate">{{ item.label }}</span>
<span class="font-mono text-xs text-muted">{{ item.hint }}</span>
</span>
</template>
</USelect>
</DirectoryField>
<URadioGroup
:model-value="kind"
:items="kindItems"
:disabled="frozen"
:legend="t('How are they connected?')"
variant="card"
indicator="hidden"
orientation="horizontal"
:ui="{
...CARD_UI,
fieldset: compact ? 'grid grid-cols-1 gap-2' : 'grid grid-cols-3 gap-2',
}"
@update:model-value="onKind"
>
<template #label="{ item }">
<span
class="flex flex-col gap-1.5 text-left"
:class="frozen && !(item.value === kind) ? 'opacity-60' : ''"
>
<span class="flex items-center gap-1.5" aria-hidden="true">
<span class="flex gap-0.75">
<span
v-for="square in squares(DIAGRAMS[item.value as RelationKind][0])"
:key="square"
class="size-2.5 bg-current"
/>
</span>
<span class="h-px w-5 bg-current" />
<span class="flex gap-0.75">
<span
v-for="square in squares(DIAGRAMS[item.value as RelationKind][1])"
:key="square"
class="size-2.5 bg-current"
/>
</span>
</span>
<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)"
>{{ item.sentence }}</span
>
</span>
</template>
</URadioGroup>
<div data-testid="relation-preview" class="flex flex-col gap-2 bg-elevated p-3">
<span class="flex flex-wrap items-center" data-testid="relation-owner-sentence">
{{ t('Each {0} has', [names.ownerSingular]) }}
<USelect
:model-value="ownerSide(kind)"
:items="sideItems"
:disabled="frozen"
:aria-label="t('Each {0} has', [names.ownerSingular])"
:ui="SIDE_UI"
@update:model-value="onOwnerSide"
/>
{{ ownerNoun(kind, names) }}
</span>
<span class="flex flex-wrap items-center" data-testid="relation-inverse-sentence">
{{ t('Each {0} has', [names.relatedSingular]) }}
<USelect
:model-value="inverseSide(kind)"
:items="sideItems"
:disabled="frozen"
:aria-label="t('Each {0} has', [names.relatedSingular])"
:ui="SIDE_UI"
@update:model-value="onInverseSide"
/>
{{ inverseNoun(kind, names) }}
<span class="text-muted">{{
` · ${t('shown on {0} as the field "{1}"', [names.relatedSingular, names.ownerPlural])}`
}}</span>
</span>
</div>
<div class="flex gap-3" :class="compact ? 'flex-col' : 'flex-row'">
<div
data-testid="relation-example-owner"
class="flex min-w-0 flex-1 flex-col gap-2 border border-default bg-default p-3"
>
<span class="font-semibold text-highlighted">{{ names.ownerSingular }}</span>
<span class="text-xs text-muted">{{ ownerNoun(kind, names) }}</span>
<div class="flex flex-wrap items-center gap-1.5">
<RelationChip
v-for="chip in sample.ownerChips"
:key="chip"
:label="chip"
tall
/>
<span v-if="sample.ownerMore" class="self-center text-xs text-muted"
>+{{ sample.ownerMore }}</span
>
</div>
</div>
<div
data-testid="relation-example-related"
class="flex min-w-0 flex-1 flex-col gap-2 border border-default bg-default p-3"
>
<span class="font-semibold text-highlighted">{{ names.relatedSingular }}</span>
<span class="text-xs text-muted">{{ inverseNoun(kind, names) }}</span>
<div class="flex flex-wrap items-center gap-1.5">
<RelationChip
v-for="chip in sample.relatedChips"
:key="chip"
:label="chip"
tall
/>
<span v-if="sample.relatedMore" class="self-center text-xs text-muted"
>+{{ sample.relatedMore }}</span
>
</div>
</div>
</div>
</div>
</template>
+2
View File
@@ -262,6 +262,7 @@ async function restoreRowFocus() {
:field="builder.selected.value" :field="builder.selected.value"
:errors="selectedErrors" :errors="selectedErrors"
:compact="false" :compact="false"
:owner-name="form.name"
/> />
</template> </template>
<template v-else> <template v-else>
@@ -320,6 +321,7 @@ async function restoreRowFocus() {
:focus-token="builder.labelFocusToken.value" :focus-token="builder.labelFocusToken.value"
:field="builder.selected.value" :field="builder.selected.value"
:errors="selectedErrors" :errors="selectedErrors"
:owner-name="form.name"
compact compact
/> />
</FieldSheet> </FieldSheet>
@@ -35,6 +35,7 @@ watch(
:name="remove.type.value.name" :name="remove.type.value.name"
:key-value="remove.type.value.key" :key-value="remove.type.value.key"
:total="remove.total.value" :total="remove.total.value"
:referrer="remove.referrer.value"
:pending="remove.pending.value" :pending="remove.pending.value"
:offline="remove.offline.value" :offline="remove.offline.value"
@confirm="remove.confirm" @confirm="remove.confirm"
+3 -3
View File
@@ -7,7 +7,7 @@ import SchemaChangeList from '@/components/SchemaChangeList.vue'
import { entriesLine } from '@/lib/schemaEntries' import { entriesLine } from '@/lib/schemaEntries'
import { import {
hasRefusedChange, hasRefusedChange,
isMediaOnlyClean, isNoDefaultOnlyClean,
summaryLine, summaryLine,
type FieldChange, type FieldChange,
} from '@/lib/schemaBuilderDiff' } from '@/lib/schemaBuilderDiff'
@@ -25,7 +25,7 @@ defineEmits<{ apply: []; close: [] }>()
const summary = computed(() => summaryLine(props.changes)) const summary = computed(() => summaryLine(props.changes))
const entries = computed(() => entriesLine(props.name, props.total)) const entries = computed(() => entriesLine(props.name, props.total))
const refused = computed(() => hasRefusedChange(props.changes)) const refused = computed(() => hasRefusedChange(props.changes))
const mediaOnlyClean = computed(() => isMediaOnlyClean(props.changes)) const noDefaultOnlyClean = computed(() => isNoDefaultOnlyClean(props.changes))
</script> </script>
<template> <template>
@@ -53,7 +53,7 @@ const mediaOnlyClean = computed(() => isMediaOnlyClean(props.changes))
<p v-if="refused" class="text-xs/4.5 text-muted"> <p v-if="refused" class="text-xs/4.5 text-muted">
{{ t('Back to editing keeps your other changes.') }} {{ t('Back to editing keeps your other changes.') }}
</p> </p>
<p v-else-if="!mediaOnlyClean && !applying" class="text-xs/4.5 text-muted"> <p v-else-if="!noDefaultOnlyClean && !applying" class="text-xs/4.5 text-muted">
{{ {{
t( t(
"Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed.", "Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed.",
@@ -0,0 +1,232 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { ApiError } from '@/composables/useApi'
import { RELATION_SEARCH_DEBOUNCE_MS, useRelationSearch } from '@/composables/useRelationSearch'
import type { ContentEntry } from '@/lib/contentTypes'
import { RELATION_PAGE_SIZE } from '@/lib/relationField'
const searchEntries = vi.fn()
vi.mock('@/composables/useEntries', () => ({
useEntries: () => ({ searchEntries }),
}))
interface Deferred {
resolve: (value: unknown) => void
reject: (error: unknown) => void
}
let wrappers: VueWrapper[] = []
function setup() {
let api!: ReturnType<typeof useRelationSearch>
const wrapper = mount(
defineComponent({
setup() {
api = useRelationSearch(() => 'venues')
return () => h('div')
},
}),
)
wrappers.push(wrapper)
return api
}
function entry(id: number): ContentEntry {
return { id, data: {} } as unknown as ContentEntry
}
function answer(ids: number[], total: number, page = 1) {
return { items: ids.map(entry), total, page, size: RELATION_PAGE_SIZE }
}
function deferred() {
const pending: Deferred[] = []
searchEntries.mockImplementation(
() =>
new Promise((resolve, reject) => {
pending.push({ resolve, reject })
}),
)
return pending
}
async function debounce() {
await vi.advanceTimersByTimeAsync(RELATION_SEARCH_DEBOUNCE_MS)
await flushPromises()
}
beforeEach(() => {
vi.useFakeTimers()
searchEntries.mockReset()
})
afterEach(() => {
wrappers.forEach((wrapper) => wrapper.unmount())
wrappers = []
vi.useRealTimers()
})
describe('useRelationSearch query', () => {
it('tracks the latest trimmed query across consecutive searches', async () => {
searchEntries.mockResolvedValue(answer([], 0))
const search = setup()
search.search(' alpha ')
await debounce()
expect(search.query.value).toBe('alpha')
search.search('beta')
await debounce()
expect(search.query.value).toBe('beta')
expect(searchEntries).toHaveBeenLastCalledWith('venues', {
q: 'beta',
page: 1,
size: RELATION_PAGE_SIZE,
})
search.search('')
await debounce()
expect(search.query.value).toBe('')
})
})
describe('useRelationSearch debounce', () => {
it('issues one request for rapid searches', async () => {
searchEntries.mockResolvedValue(answer([1], 1))
const search = setup()
search.search('a')
await vi.advanceTimersByTimeAsync(RELATION_SEARCH_DEBOUNCE_MS - 1)
search.search('ab')
await vi.advanceTimersByTimeAsync(RELATION_SEARCH_DEBOUNCE_MS - 1)
expect(searchEntries).not.toHaveBeenCalled()
await debounce()
expect(searchEntries).toHaveBeenCalledTimes(1)
expect(searchEntries.mock.calls[0]![1].q).toBe('ab')
expect(search.status.value).toBe('ready')
})
})
describe('useRelationSearch stale responses', () => {
it('discards a page-1 answer superseded by a newer load', async () => {
const pending = deferred()
const search = setup()
search.search('old')
await debounce()
search.search('new')
await debounce()
expect(pending).toHaveLength(2)
pending[1]!.resolve(answer([2], 1))
await flushPromises()
pending[0]!.resolve(answer([1], 1))
await flushPromises()
expect(search.entries.value.map((item) => item.id)).toEqual([2])
})
it('discards an in-flight answer when a new search starts during the debounce', async () => {
const pending = deferred()
const search = setup()
search.search('old')
await debounce()
search.search('new')
pending[0]!.resolve(answer([1], 1))
await flushPromises()
expect(search.entries.value).toEqual([])
expect(search.status.value).toBe('loading')
})
it('discards an answer after stop', async () => {
const pending = deferred()
const search = setup()
search.search('x')
await debounce()
search.stop()
pending[0]!.resolve(answer([1], 1))
await flushPromises()
expect(search.entries.value).toEqual([])
})
})
describe('useRelationSearch load more', () => {
it('appends the next page and ends when everything is loaded', async () => {
searchEntries
.mockResolvedValueOnce(answer([1, 2], 3))
.mockResolvedValueOnce(answer([3], 3, 2))
const search = setup()
await search.reload()
expect(search.hasMore.value).toBe(true)
await search.more()
expect(searchEntries).toHaveBeenLastCalledWith('venues', {
q: '',
page: 2,
size: RELATION_PAGE_SIZE,
})
expect(search.entries.value.map((item) => item.id)).toEqual([1, 2, 3])
expect(search.hasMore.value).toBe(false)
expect(search.more()).toBeUndefined()
})
it('starts the limiter on a 429 and keeps the loaded rows', async () => {
searchEntries
.mockResolvedValueOnce(answer([1], 5))
.mockRejectedValueOnce(new ApiError(429, 7))
const search = setup()
await search.reload()
await search.more()
expect(search.status.value).toBe('limited')
expect(search.limit.remaining.value).toBe(7)
expect(search.entries.value.map((item) => item.id)).toEqual([1])
expect(search.loadingMore.value).toBe(false)
})
it('surfaces other errors as failed and keeps the loaded rows', async () => {
searchEntries.mockResolvedValueOnce(answer([1], 5)).mockRejectedValueOnce(new ApiError(500))
const search = setup()
await search.reload()
await search.more()
expect(search.status.value).toBe('failed')
expect(search.entries.value.map((item) => item.id)).toEqual([1])
})
it('goes idle on a 401 during load more', async () => {
searchEntries.mockResolvedValueOnce(answer([1], 5)).mockRejectedValueOnce(new ApiError(401))
const search = setup()
await search.reload()
await search.more()
expect(search.status.value).toBe('idle')
})
it('does not request page+1 of a stale set after a failed page-1', async () => {
searchEntries
.mockResolvedValueOnce(answer([1], 5))
.mockRejectedValueOnce(new ApiError(500))
.mockResolvedValueOnce(answer([9], 9))
const search = setup()
await search.reload()
await search.reload()
expect(search.status.value).toBe('failed')
await search.reload()
await search.more()
expect(searchEntries.mock.calls.map((call) => call[1].page)).toEqual([1, 1, 1, 2])
})
})
+22 -1
View File
@@ -3,6 +3,12 @@ import type { ContentEntry, EntryPage, EntryPageQuery } from '@/lib/contentTypes
import { entryEndpoint } from '@/lib/contentEndpoints' import { entryEndpoint } from '@/lib/contentEndpoints'
import { jsonInit } from '@/lib/directoryRequest' import { jsonInit } from '@/lib/directoryRequest'
export interface EntrySearchQuery {
q: string
page: number
size: number
}
export function useEntries() { export function useEntries() {
const { request } = useApi() const { request } = useApi()
const options = { onForbidden: 'passthrough' } as const const options = { onForbidden: 'passthrough' } as const
@@ -21,6 +27,21 @@ export function useEntries() {
return page return page
} }
async function searchEntries(key: string, query: EntrySearchQuery): Promise<EntryPage> {
const params = new URLSearchParams()
if (query.q !== '') {
params.set('q', query.q)
}
params.set('page', String(query.page))
params.set('size', String(query.size))
const url = `${entryEndpoint(key)}?${params.toString()}`
const page = await request<EntryPage>(url, options)
if (!Array.isArray(page?.items) || typeof page.total !== 'number') {
throw new Error(`Unexpected response from ${url}`)
}
return page
}
async function requestEntry(url: string, init: RequestInit): Promise<ContentEntry> { async function requestEntry(url: string, init: RequestInit): Promise<ContentEntry> {
const entry = await request<ContentEntry>(url, { ...init, ...options }) const entry = await request<ContentEntry>(url, { ...init, ...options })
if ( if (
@@ -49,5 +70,5 @@ export function useEntries() {
await request<void>(entryEndpoint(key, id), { method: 'DELETE', ...options }) await request<void>(entryEndpoint(key, id), { method: 'DELETE', ...options })
} }
return { listEntries, getEntry, createEntry, saveEntry, deleteEntry } return { listEntries, searchEntries, getEntry, createEntry, saveEntry, deleteEntry }
} }
+14 -3
View File
@@ -6,6 +6,7 @@ import { useEntryForm } from '@/composables/useEntryForm'
import { useEntryLoad } from '@/composables/useEntryLoad' import { useEntryLoad } from '@/composables/useEntryLoad'
import type { ContentEntry } from '@/lib/contentTypes' import type { ContentEntry } from '@/lib/contentTypes'
import type { MediaSummary } from '@/lib/mediaField' import type { MediaSummary } from '@/lib/mediaField'
import type { RelationSummary } from '@/lib/relationField'
import { resolveEditorPhase, type EntryTarget } from '@/lib/entryEditorState' import { resolveEditorPhase, type EntryTarget } from '@/lib/entryEditorState'
import { buildData, issueFor, validateEntry } from '@/lib/entryForm' import { buildData, issueFor, validateEntry } from '@/lib/entryForm'
import { classifyRefusal, isGoneError, parseEntryRefusal } from '@/lib/entryServerError' import { classifyRefusal, isGoneError, parseEntryRefusal } from '@/lib/entryServerError'
@@ -92,7 +93,12 @@ export function useEntryDraft(typeKey: string, target: EntryTarget) {
return null return null
} }
failure.value = 'none' failure.value = 'none'
const issues = validateEntry(fields.value, form.values.value, form.images.value) const issues = validateEntry(
fields.value,
form.values.value,
form.images.value,
form.relations.value,
)
if (Object.keys(issues).length > 0) { if (Object.keys(issues).length > 0) {
form.reject(issues) form.reject(issues)
focusToken.value++ focusToken.value++
@@ -114,8 +120,13 @@ export function useEntryDraft(typeKey: string, target: EntryTarget) {
} }
} }
function change(key: string, value: unknown, image?: MediaSummary | null) { function change(
form.setValue(key, value, image) key: string,
value: unknown,
image?: MediaSummary | null,
summaries?: RelationSummary[],
) {
form.setValue(key, value, image, summaries)
} }
function reload() { function reload() {
+18 -1
View File
@@ -1,10 +1,12 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes' import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import type { MediaMap, MediaSummary } from '@/lib/mediaField' import type { MediaMap, MediaSummary } from '@/lib/mediaField'
import type { RelationSummaries, RelationSummary } from '@/lib/relationField'
import { import {
carriesDefault, carriesDefault,
firstIssueKey, firstIssueKey,
initialImages, initialImages,
initialRelations,
initialValues, initialValues,
valuesDiffer, valuesDiffer,
type EntryIssues, type EntryIssues,
@@ -16,6 +18,7 @@ export function useEntryForm(fields: () => BuiltinField[], creating: () => boole
const values = ref<EntryValues>({}) const values = ref<EntryValues>({})
const saved = ref<EntryValues>({}) const saved = ref<EntryValues>({})
const images = ref<MediaMap>({}) const images = ref<MediaMap>({})
const relations = ref<RelationSummaries>({})
const issues = ref<EntryIssues>({}) const issues = ref<EntryIssues>({})
const rejected = ref(false) const rejected = ref(false)
@@ -28,6 +31,7 @@ export function useEntryForm(fields: () => BuiltinField[], creating: () => boole
values.value = { ...next } values.value = { ...next }
saved.value = { ...next } saved.value = { ...next }
images.value = initialImages(fields(), entry) images.value = initialImages(fields(), entry)
relations.value = initialRelations(fields(), entry)
issues.value = {} issues.value = {}
rejected.value = false rejected.value = false
} }
@@ -45,9 +49,21 @@ export function useEntryForm(fields: () => BuiltinField[], creating: () => boole
images.value = next images.value = next
} }
function setValue(key: string, value: unknown, image?: MediaSummary | null) { function setRelations(key: string, summaries: RelationSummary[] | undefined) {
if (summaries !== undefined) {
relations.value = { ...relations.value, [key]: summaries }
}
}
function setValue(
key: string,
value: unknown,
image?: MediaSummary | null,
summaries?: RelationSummary[],
) {
values.value = { ...values.value, [key]: value } values.value = { ...values.value, [key]: value }
setImage(key, image) setImage(key, image)
setRelations(key, summaries)
rejected.value = false rejected.value = false
if (issues.value[key]) { if (issues.value[key]) {
const rest = { ...issues.value } const rest = { ...issues.value }
@@ -72,6 +88,7 @@ export function useEntryForm(fields: () => BuiltinField[], creating: () => boole
return { return {
values, values,
images, images,
relations,
issues, issues,
rejected, rejected,
dirty, dirty,
+131
View File
@@ -0,0 +1,131 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { useEntries } from '@/composables/useEntries'
import { useRetryAfter } from '@/composables/useRetryAfter'
import type { ContentEntry } from '@/lib/contentTypes'
import { RELATION_PAGE_SIZE } from '@/lib/relationField'
export const RELATION_SEARCH_DEBOUNCE_MS = 200
const UNAUTHORIZED = 401
const RATE_LIMITED = 429
const RATE_LIMIT_FALLBACK_SECONDS = 30
export type RelationSearchStatus = 'idle' | 'loading' | 'ready' | 'failed' | 'limited'
export function useRelationSearch(target: () => string) {
const { searchEntries } = useEntries()
const skeleton = useDelayedSkeleton()
const limit = useRetryAfter()
const entries = ref<ContentEntry[]>([])
const total = ref(0)
const page = ref(0)
const status = ref<RelationSearchStatus>('idle')
const loadingMore = ref(false)
const query = ref('')
let token = 0
let timer: ReturnType<typeof setTimeout> | null = null
const hasMore = computed(() => status.value === 'ready' && entries.value.length < total.value)
const searching = computed(() => status.value === 'loading' && !loadingMore.value)
const showSkeleton = computed(() => searching.value && skeleton.visible.value)
function clearTimer() {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
}
function fail(error: unknown) {
if (error instanceof ApiError && error.status === UNAUTHORIZED) {
status.value = 'idle'
} else if (error instanceof ApiError && error.status === RATE_LIMITED) {
limit.start(error.retryAfter ?? RATE_LIMIT_FALLBACK_SECONDS)
status.value = 'limited'
} else {
status.value = 'failed'
}
}
async function load(pageNumber: number) {
const current = ++token
loadingMore.value = pageNumber > 1
status.value = 'loading'
skeleton.start()
try {
const answer = await searchEntries(target(), {
q: query.value,
page: pageNumber,
size: RELATION_PAGE_SIZE,
})
if (current !== token) {
return
}
entries.value = pageNumber === 1 ? answer.items : [...entries.value, ...answer.items]
total.value = answer.total
page.value = pageNumber
status.value = 'ready'
} catch (error) {
if (current === token) {
if (pageNumber === 1) {
page.value = 0
}
fail(error)
}
} finally {
if (current === token) {
skeleton.stop()
loadingMore.value = false
}
}
}
function search(text: string) {
clearTimer()
query.value = text.trim()
token++
skeleton.stop()
loadingMore.value = false
timer = setTimeout(() => {
timer = null
load(1)
}, RELATION_SEARCH_DEBOUNCE_MS)
}
function reload() {
clearTimer()
return load(1)
}
function more() {
if (hasMore.value) {
return load(page.value + 1)
}
}
function stop() {
clearTimer()
token++
skeleton.stop()
loadingMore.value = false
}
onBeforeUnmount(stop)
return {
entries,
total,
status,
query,
hasMore,
loadingMore,
showSkeleton,
limit,
search,
reload,
more,
stop,
}
}
+10
View File
@@ -12,6 +12,7 @@ import { SCHEMA_PATH } from '@/lib/routePaths'
import { useContentTypesStore } from '@/stores/contentTypes' import { useContentTypesStore } from '@/stores/contentTypes'
const HAS_ENTRIES_BODY = 'content_type_has_entries' const HAS_ENTRIES_BODY = 'content_type_has_entries'
const REFERENCED_BODY = 'content_type_referenced'
const TOAST_ID = 'content-type-deleted' const TOAST_ID = 'content-type-deleted'
export function useSchemaDelete(source: () => EditSource) { export function useSchemaDelete(source: () => EditSource) {
@@ -27,6 +28,12 @@ export function useSchemaDelete(source: () => EditSource) {
const offline = computed(() => !online.value) const offline = computed(() => !online.value)
const type = computed(() => source().type) const type = computed(() => source().type)
const total = computed(() => source().total) const total = computed(() => source().total)
const referrer = computed(
() =>
store.items.find((candidate) =>
candidate.fields.some((field) => field.relation?.content_type === type.value.key),
)?.name ?? '',
)
function open() { function open() {
if (pending.value) { if (pending.value) {
@@ -60,6 +67,8 @@ export function useSchemaDelete(source: () => EditSource) {
forbidden.value = true forbidden.value = true
} else if (status === 409 && body === HAS_ENTRIES_BODY) { } else if (status === 409 && body === HAS_ENTRIES_BODY) {
stage.value = 'conflict' stage.value = 'conflict'
} else if (status === 409 && body === REFERENCED_BODY) {
stage.value = 'referenced'
} else { } else {
stage.value = 'failed' stage.value = 'failed'
} }
@@ -95,6 +104,7 @@ export function useSchemaDelete(source: () => EditSource) {
offline, offline,
type, type,
total, total,
referrer,
open, open,
close, close,
viewEntries, viewEntries,
+1 -1
View File
@@ -16,7 +16,7 @@ const SOURCES = [
'lib/schemaBuilderDiff.ts', 'lib/schemaBuilderDiff.ts',
] ]
const SAME_IN_FRENCH = new Set<string>(['Date']) const SAME_IN_FRENCH = new Set<string>(['Date', 'Relation'])
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
+55
View File
@@ -2663,6 +2663,61 @@ export const messages = {
"A media field can't have a default, so the {0} existing entries would have no image. Make {1} optional to add it.": "A media field can't have a default, so the {0} existing entries would have no image. Make {1} optional to add it.":
'Un champ média ne peut pas avoir de valeur par défaut, donc les {0} entrées existantes n’auraient pas d’image. Rendez {1} facultatif pour l’ajouter.', 'Un champ média ne peut pas avoir de valeur par défaut, donc les {0} entrées existantes n’auraient pas d’image. Rendez {1} facultatif pour l’ajouter.',
'{0} entries are unaffected.': '{0} entrées ne sont pas concernées.', '{0} entries are unaffected.': '{0} entrées ne sont pas concernées.',
'Entry no longer exists': 'Cette entrée n’existe plus',
'Choose a {0}.': 'Choisissez une valeur pour {0}.',
'{0} must point to an existing entry.': '{0} doit pointer vers une entrée existante.',
'That {0} is already used by another entry.':
'Cette valeur de {0} est déjà utilisée par une autre entrée.',
Relation: 'Relation',
"Links to another type's entries": 'Renvoie vers les entrées d’un autre type',
"A relation can't have a default, so the existing entry would have no {0}. Make {0} optional to add it.":
'Une relation ne peut pas avoir de valeur par défaut, donc l’entrée existante n’aurait pas de valeur pour {0}. Rendez {0} facultatif pour l’ajouter.',
"A relation can't have a default, so the {0} existing entries would have no {1}. Make {1} optional to add it.":
'Une relation ne peut pas avoir de valeur par défaut, donc les {0} entrées existantes n’auraient pas de valeur pour {1}. Rendez {1} facultatif pour l’ajouter.',
Entries: 'Entrées',
'Each {0} has one {1}, and each {1} has one {0}.':
'Chaque {0} a un seul {1}, et chaque {1} a un seul {0}.',
'Each {0} has one {1}. A {1} has many {2}.':
'Chaque {0} a un seul {1}. Un {1} a plusieurs {2}.',
'Each {0} has many {1}, and each {2} has many {3}.':
'Chaque {0} a plusieurs {1}, et chaque {2} a plusieurs {3}.',
'Choose a related content type.': 'Choisissez un type de contenu lié.',
'{0} has a field that points to {1}. Remove that field first.':
'{0} a un champ qui pointe vers {1}. Supprimez d’abord ce champ.',
'Another content type has a field that points to {0}. Remove that field first.':
'Un autre type de contenu a un champ qui pointe vers {0}. Supprimez d’abord ce champ.',
'{0} is required. Choose another to save.':
'{0} est obligatoire. Choisissez une autre valeur pour enregistrer.',
'An {0} can have many {1}.': 'Un {0} peut avoir plusieurs {1}.',
'A {0} can have many {1}.': 'Un {0} peut avoir plusieurs {1}.',
'Remove {0} from {1}': 'Retirer {0} de {1}',
'{0} set to {1}.': '{0} défini sur {1}.',
'{0} removed from {1}.': '{0} retiré de {1}.',
'{0} added to {1}.': '{0} ajouté à {1}.',
'{0} results': '{0} résultats',
'No results': 'Aucun résultat',
'Search to add': 'Rechercher pour ajouter',
'Searching…': 'Recherche en cours…',
"Couldn't load {0}": 'Impossible de charger {0}',
'You can add up to {0}.': 'Vous pouvez en ajouter jusqu’à {0}.',
'No {0} yet. Add one in Content, then come back.':
'Aucun élément dans {0} pour l’instant. Ajoutez-en depuis Contenu, puis revenez.',
'No {0} match "{1}"': 'Aucun résultat dans {0} pour « {1} »',
'No other {0} to add.': 'Aucun autre élément de {0} à ajouter.',
one: 'un',
many: 'plusieurs',
'One to one': 'Un à un',
'One to many': 'Un à plusieurs',
'Many to many': 'Plusieurs à plusieurs',
"The related type and the connection can't change after saving.":
'Le type lié et la connexion ne peuvent plus changer après l’enregistrement.',
'Related content type': 'Type de contenu lié',
'Choose the content type this field points to.':
'Choisissez le type de contenu vers lequel ce champ pointe.',
'Choose a content type': 'Choisissez un type de contenu',
'How are they connected?': 'Comment sont-ils liés ?',
'Each {0} has': 'Chaque {0} a',
'shown on {0} as the field "{1}"': 'affiché sur {0} comme le champ « {1} »',
} as const } as const
export type MessageKey = keyof typeof messages export type MessageKey = keyof typeof messages
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { messages } from './messages'
const SOURCES = [
'components/RelationField.vue',
'components/RelationPicker.vue',
'components/DeleteTypeDialog.vue',
'lib/relationSentences.ts',
'lib/fieldTypes.ts',
'lib/entryCells.ts',
'lib/entryMessages.ts',
'lib/schemaBuilderValidation.ts',
]
const SAME_IN_FRENCH = new Set<string>(['Relation'])
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
function keysIn(source: string) {
return [...source.matchAll(LITERAL)].map((match) =>
(match[1] ?? match[2] ?? '').replace(/\\u0022/g, '"').replace(/\\(['"\\])/g, '$1'),
)
}
describe('Relation field messages', () => {
const keys = SOURCES.flatMap((file) =>
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
file,
key,
})),
)
it('finds the strings of the feature', () => {
expect(keys.length).toBeGreaterThan(40)
})
it.each(keys.map(({ file, key }) => [file, key]))(
'%s: "%s" has its own French entry',
(_file, key) => {
const french = (messages as Record<string, string>)[key as string]
expect(french).toMatch(/\S/)
if (
!SAME_IN_FRENCH.has(key as string) &&
!(key as string).match(/^(Date|Text|Number)$/)
) {
expect(french).not.toBe(key)
}
},
)
})
+1
View File
@@ -10,6 +10,7 @@ const CONTROLS: Record<FieldType, string> = {
date: 'date', date: 'date',
select: 'select', select: 'select',
media: 'media', media: 'media',
relation: 'relation',
} }
function editor(type: FieldType) { function editor(type: FieldType) {
+5 -3
View File
@@ -50,9 +50,10 @@ function mediaField(rest: object = {}) {
const media = ENTRY_EDITORS.media const media = ENTRY_EDITORS.media
describe('media in the registry', () => { describe('media in the registry', () => {
it('is the seventh type, after select', () => { it('is the seventh type, after select and before relation', () => {
expect(FIELD_TYPES).toHaveLength(7) expect(FIELD_TYPES).toHaveLength(8)
expect(FIELD_TYPES[6]).toBe('media') expect(FIELD_TYPES[6]).toBe('media')
expect(FIELD_TYPES[7]).toBe('relation')
expect(FIELD_TYPES[5]).toBe('select') expect(FIELD_TYPES[5]).toBe('select')
}) })
@@ -82,7 +83,7 @@ describe('media in the registry', () => {
) )
}) })
it.each(FIELD_TYPES.filter((type) => type !== 'media'))( it.each(FIELD_TYPES.filter((type) => type !== 'media' && type !== 'relation'))(
'%s keeps its builder options and has no hint', '%s keeps its builder options and has no hint',
(type) => { (type) => {
const spec = fieldTypeSpec(type) const spec = fieldTypeSpec(type)
@@ -374,6 +375,7 @@ describe('media in the schema builder', () => {
min: null, min: null,
max: null, max: null,
options: [], options: [],
relation: null,
}) })
expect(payloadField({ ...field, required: true }).required).toBe(true) expect(payloadField({ ...field, required: true }).required).toBe(true)
}) })
+483
View File
@@ -0,0 +1,483 @@
import { describe, expect, it } from 'vitest'
import type { ContentEntry } from '@/lib/contentTypes'
import { describeCell, plainCellText, relationLabel } from '@/lib/entryCells'
import { ENTRY_EDITORS } from '@/lib/entryEditors'
import {
buildData,
entryTitle,
initialRelations,
initialValues,
validateEntry,
valuesDiffer,
} from '@/lib/entryForm'
import { ruleMessage } from '@/lib/entryMessages'
import { groupFields } from '@/lib/entryLayout'
import { classifyRefusal, parseEntryRefusal } from '@/lib/entryServerError'
import { FIELD_TYPES, fieldTypeSpec } from '@/lib/fieldTypes'
import { isRelationId, relationIdsOf, summariesOf, type RelationSummary } from '@/lib/relationField'
import { relationRowOf } from '@/lib/relationRows'
import { ApiError } from '@/lib/apiError'
import { diffFields } from '@/lib/schemaBuilderDiff'
import { baselinePayload, fieldFromApi, formFromType } from '@/lib/schemaBuilderLoad'
import { createField } from '@/lib/schemaBuilderModel'
import { buildPayload, payloadField } from '@/lib/schemaBuilderPayload'
import { sampleEntry } from '@/lib/schemaBuilderSample'
import { validateField } from '@/lib/schemaBuilderValidation'
import { apiField } from './schemaFixtures'
const relation = ENTRY_EDITORS.relation
function field(kind: 'one_to_one' | 'one_to_many' | 'many_to_many', rest: object = {}) {
return apiField('Venue', 'venue', 'relation', {
options: [],
relation: { content_type: 'venues', kind },
...rest,
})
}
const single = field('one_to_many')
const many = field('many_to_many')
function target(id: number, name: string | null): RelationSummary {
return {
id,
content_type: 'venues',
first_field: { key: 'name', type: 'text', value: name },
}
}
describe('relation in the registry', () => {
it('is the eighth type, after media', () => {
expect(FIELD_TYPES).toHaveLength(8)
expect(FIELD_TYPES[7]).toBe('relation')
expect(FIELD_TYPES[6]).toBe('media')
})
it('describes itself', () => {
const spec = fieldTypeSpec('relation')
expect(spec.label()).toBe('Relation')
expect(spec.description()).toBe("Links to another type's entries")
expect(spec.icon).toBe('i-lucide-link')
expect(spec.editor.control).toBe('relation')
expect(spec.cell).toBe('relation')
expect(spec.pairs).toBe(false)
expect(spec.supportsUnique).toBe(false)
expect(spec.supportsLimits).toBe(false)
expect(spec.supportsDefault).toBe(false)
expect(spec.supportsChoices).toBe(false)
expect(spec.showsUnsupportedOptions).toBe(false)
expect(spec.builderHint).toBeNull()
expect(spec.holdsRelation).toBe(true)
})
it('is never the fallback type, while an unknown type still is', () => {
expect(fieldTypeSpec('relation')).not.toBe(fieldTypeSpec('text'))
expect(fieldTypeSpec('mystery')).toBe(fieldTypeSpec('text'))
expect(fieldFromApi(single, 1).type).toBe('relation')
})
it('has no counter and no help of its own', () => {
expect(relation.counter(single, 3)).toBeNull()
expect(relation.help(single)).toBe('')
})
it('never pairs with a neighbour', () => {
const fields = [
apiField('Online', 'online', 'boolean'),
single,
apiField('Cap', 'cap', 'number'),
]
expect(groupFields(fields).map((row) => row.length)).toEqual([1, 1, 1])
})
})
describe('empty and initial values', () => {
it.each([
['one_to_one', null],
['one_to_many', null],
['many_to_many', []],
] as const)('starts empty for %s on create', (kind, empty) => {
expect(relation.initial(field(kind), 3, true)).toEqual(empty)
})
it('has no default, so no Default tag ever shows', () => {
expect(relation.initial(field('one_to_many', { default: 3 }), undefined, true)).toBeNull()
})
it('takes the stored id or ids on edit', () => {
expect(relation.initial(single, 3, false)).toBe(3)
expect(relation.initial(single, null, false)).toBeNull()
expect(relation.initial(many, [11, 12], false)).toEqual([11, 12])
expect(relation.initial(many, undefined, false)).toEqual([])
})
it('copies the stored array', () => {
const stored = [11]
expect(relation.initial(many, stored, false)).not.toBe(stored)
})
})
describe('client validation of a relation value', () => {
it.each([
['single', true, null, 'required'],
['single', false, null, null],
['single', true, 3, null],
['single', false, 0, 'type'],
['single', false, '3', 'type'],
['single', false, [3], 'type'],
['many', true, [], 'required'],
['many', false, [], null],
['many', false, [11, 12], null],
['many', false, [11, 11], 'type'],
['many', false, 11, 'type'],
['many', false, Array.from({ length: 51 }, (_, index) => index + 1), 'type'],
['many', false, Array.from({ length: 50 }, (_, index) => index + 1), null],
])('%s required=%s value %j gives %s', (kind, required, value, rule) => {
const subject = kind === 'single' ? single : many
expect(relation.validate({ ...subject, required }, value)).toBe(rule)
})
it('treats a required relation whose targets are all missing as empty', () => {
const gone: RelationSummary = { id: 3, missing: true }
expect(
relation.validate({ ...single, required: true }, 3, { image: null, relations: [gone] }),
).toBe('required')
expect(
relation.validate({ ...single, required: false }, 3, {
image: null,
relations: [gone],
}),
).toBeNull()
expect(
relation.validate({ ...many, required: true }, [3, 4], {
image: null,
relations: [gone, { id: 4, missing: true }],
}),
).toBe('required')
expect(
relation.validate({ ...many, required: true }, [3, 4], {
image: null,
relations: [gone, target(4, 'Hall')],
}),
).toBeNull()
})
it('never produces min, max or options', () => {
const rules = [null, 0, '3', [3], [], [1, 1], 5].flatMap((value) =>
[single, many].map((subject) =>
relation.validate({ ...subject, required: true }, value),
),
)
expect(rules.filter((rule) => ['min', 'max', 'options'].includes(rule ?? ''))).toEqual([])
})
})
describe('messages of a relation field', () => {
it.each([
['required', 'Choose a Venue.'],
['type', 'Venue must point to an existing entry.'],
['unique', 'That Venue is already used by another entry.'],
] as const)('the rule %s reads "%s"', (rule, message) => {
expect(ruleMessage(rule, single)).toBe(message)
})
})
describe('the payload', () => {
it('sends the id, null, or an array of ids', () => {
expect(relation.payload(single, 3)).toBe(3)
expect(relation.payload(single, null)).toBeNull()
expect(relation.payload(single, undefined)).toBeNull()
expect(relation.payload(many, [11, 12])).toEqual([11, 12])
expect(relation.payload(many, undefined)).toEqual([])
})
it('puts null and [] in a create body, never absent', () => {
const fields = [single, { ...many, key: 'speakers' }]
const body = buildData(fields, initialValues(fields, null))
expect(body).toEqual({ venue: null, speakers: [] })
expect(Object.keys(body)).toEqual(['venue', 'speakers'])
})
})
describe('the ids helpers', () => {
it('accepts uint32 ids of at least 1', () => {
expect(isRelationId(1)).toBe(true)
expect(isRelationId(4294967295)).toBe(true)
expect(isRelationId(0)).toBe(false)
expect(isRelationId(4294967296)).toBe(false)
expect(isRelationId(1.5)).toBe(false)
expect(isRelationId('3')).toBe(false)
})
it('reads one id or many', () => {
expect(relationIdsOf(3)).toEqual([3])
expect(relationIdsOf([3, 'x', 4])).toEqual([3, 4])
expect(relationIdsOf(null)).toEqual([])
})
})
describe('summaries of an entry', () => {
const entry = {
id: 42,
data: { venue: 3, speakers: [11, 12] },
relations: {
venue: target(3, 'Rue Verte hall'),
speakers: [target(11, 'Ines Duarte'), target(12, 'Jonas Pereira')],
},
} as unknown as ContentEntry
it('wraps a single summary in an array', () => {
expect(summariesOf(entry, 'venue').map((summary) => summary.id)).toEqual([3])
expect(summariesOf(entry, 'speakers').map((summary) => summary.id)).toEqual([11, 12])
expect(summariesOf(entry, 'other')).toEqual([])
expect(summariesOf(null, 'venue')).toEqual([])
})
it('keeps only the summaries of the stored ids', () => {
const stale = { ...entry, data: { venue: 4, speakers: [12] } } as ContentEntry
const kept = initialRelations([single, { ...many, key: 'speakers' }], stale)
expect(kept).toEqual({ speakers: [entry.relations!.speakers![1]] })
})
})
describe('dirty tracking', () => {
it('compares ids by value and by order', () => {
expect(valuesDiffer([single], { venue: 3 }, { venue: 3 })).toBe(false)
expect(valuesDiffer([many], { venue: [11, 12] }, { venue: [11, 12] })).toBe(false)
expect(valuesDiffer([many], { venue: [12, 11] }, { venue: [11, 12] })).toBe(true)
expect(valuesDiffer([many], { venue: [11] }, { venue: [11, 12] })).toBe(true)
})
})
describe('labels and cells', () => {
it('uses the first field of the target, formatted like the list', () => {
expect(relationLabel(target(3, 'Rue Verte hall'))).toBe('Rue Verte hall')
expect(
relationLabel({
id: 5,
content_type: 'x',
first_field: { key: 'on', type: 'boolean', value: true },
}),
).toBe('Yes')
expect(
relationLabel({
id: 6,
content_type: 'x',
first_field: { key: 'on', type: 'number', value: 1500 },
}),
).toBe('1,500')
})
it('falls back to Entry N for a null first field', () => {
expect(relationLabel(target(7, null))).toBe('Entry 7')
expect(
relationLabel({
id: 8,
content_type: 'x',
first_field: { key: 'm', type: 'media', value: 9 },
}),
).toBe('Entry 8')
})
it('labels a missing target', () => {
expect(relationLabel({ id: 3, missing: true })).toBe('Entry no longer exists')
})
it('describes one chip, two chips and a +N, or an empty cell', () => {
const summaries = [11, 12, 13, 14].map((id) => target(id, `Speaker ${id}`))
const cell = describeCell(many, [11, 12, 13, 14], undefined, summaries)
expect(cell.kind).toBe('relation')
expect(cell.chips!.map((chip) => chip.label)).toEqual(['Speaker 11', 'Speaker 12'])
expect(cell.more).toBe(2)
expect(plainCellText(cell)).toBe('Speaker 11, Speaker 12, Speaker 13, Speaker 14')
expect(describeCell(many, []).kind).toBe('empty')
expect(describeCell(single, null).kind).toBe('empty')
expect(plainCellText(describeCell(many, []))).toBe('–')
})
it('never shows the numeric id when no summary came with the item', () => {
const cell = describeCell(single, 3)
expect(cell.chips).toEqual([{ id: 3, label: 'Entry no longer exists', missing: true }])
expect(cell.text).not.toContain('3')
})
it('titles an entry whose first field is a relation "Entry N"', () => {
const entry = {
id: 42,
data: { venue: 3 },
relations: { venue: target(3, 'Hall') },
} as unknown as ContentEntry
expect(entryTitle([single], entry)).toBe('Entry 42')
})
})
describe('rows of the picker', () => {
const fields = [
apiField('Name', 'name', 'text'),
apiField('City', 'city', 'text'),
apiField('Seats', 'seats', 'number'),
apiField('Notes', 'notes', 'text'),
]
const entry = (data: Record<string, unknown>) => ({ id: 3, data }) as unknown as ContentEntry
it('puts the first field in the title and up to two next fields in the second line', () => {
const row = relationRowOf(
entry({ name: 'Rue Verte hall', city: 'Lyon', seats: 120, notes: 'x' }),
'venues',
fields,
)
expect(row.title).toBe('Rue Verte hall')
expect(row.detail).toBe('Lyon · 120')
expect(row.summary.first_field).toEqual({
key: 'name',
type: 'text',
value: 'Rue Verte hall',
})
})
it('skips empty fields and falls back to Entry N', () => {
const row = relationRowOf(entry({ name: '', city: '', seats: 5 }), 'venues', fields)
expect(row.title).toBe('Entry 3')
expect(row.detail).toBe('5')
})
it('copes with a type that is not known yet', () => {
const row = relationRowOf(entry({ name: 'x' }), 'venues', [])
expect(row.title).toBe('Entry 3')
expect(row.detail).toBe('')
})
})
describe('server refusals', () => {
it.each([
[422, 'invalid_entry: venue: required', 'required'],
[422, 'invalid_entry: venue: type', 'type'],
[409, 'invalid_entry: venue: unique', 'unique'],
])('maps %i %s to a rule of the field', (status, body, rule) => {
const outcome = classifyRefusal(
[single],
parseEntryRefusal(new ApiError(status, undefined, body)),
)
expect(outcome).toMatchObject({ kind: 'field', rule })
})
it('maps unknown_field to a schema change', () => {
const outcome = classifyRefusal(
[single],
parseEntryRefusal(new ApiError(422, undefined, 'invalid_entry: venue: unknown_field')),
)
expect(outcome).toEqual({ kind: 'schema', key: 'venue' })
})
})
describe('the entry validation', () => {
it('reports the first rule of each relation field', () => {
const fields = [
{ ...single, required: true },
{ ...many, key: 'speakers', required: true },
]
const issues = validateEntry(fields, { venue: null, speakers: [] })
expect(issues.venue!.rule).toBe('required')
expect(issues.speakers!.rule).toBe('required')
expect(issues.venue!.message).toBe('Choose a Venue.')
})
})
describe('relation in the builder model', () => {
const events = {
id: 1,
key: 'events',
name: 'Events',
icon: null,
created_at: '',
updated_at: '',
}
it('loads the target and the kind', () => {
const loaded = fieldFromApi(field('many_to_many'), 1)
expect(loaded.relationType).toBe('venues')
expect(loaded.relationKind).toBe('many_to_many')
expect(loaded.saved).toBe(true)
})
it('sends the relation only for a relation field', () => {
const sent = payloadField(
createField(1, {
label: 'Venue',
key: 'venue',
type: 'relation',
relationType: 'venues',
}),
)
expect(sent).toEqual({
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' },
})
expect(
payloadField(createField(2, { label: 'T', key: 't', relationType: 'venues' })).relation,
).toBeNull()
})
it('round trips a loaded field with no change', () => {
const type = { ...events, fields: [single, { ...many, key: 'speakers' }] }
let uid = 0
const form = formFromType(type, () => ++uid)
expect(buildPayload(form).fields).toEqual(baselinePayload(type))
expect(diffFields(baselinePayload(type), form.fields, 3)).toEqual([])
})
it('needs a related type', () => {
const created = createField(1, { label: 'Venue', key: 'venue', type: 'relation' })
expect(validateField(created, []).relation).toBe('Choose a related content type.')
expect(validateField({ ...created, relationType: 'venues' }, []).relation).toBeUndefined()
expect(validateField(createField(2, { label: 'T', key: 't' }), []).relation).toBeUndefined()
})
it('samples null for a single kind and [] for many_to_many', () => {
const singleField = createField(1, { label: 'Venue', key: 'venue', type: 'relation' })
const manyField = createField(2, {
label: 'Speakers',
key: 'speakers',
type: 'relation',
relationKind: 'many_to_many',
})
const data = sampleEntry('events', [singleField, manyField]).data
expect(data.venue).toBeNull()
expect(data.speakers).toEqual([])
})
})
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { ROW_DETAIL_FIELDS, relationRowOf } from '@/lib/relationRows'
function field(key: string, type: string): BuiltinField {
return { key, type, label: key } as unknown as BuiltinField
}
function entry(id: number, data: Record<string, unknown>): ContentEntry {
return { id, data } as unknown as ContentEntry
}
describe('relationRowOf', () => {
it('builds the summary from the first field', () => {
const row = relationRowOf(entry(7, { name: 'Hall' }), 'venues', [field('name', 'text')])
expect(row.id).toBe(7)
expect(row.title).toBe('Hall')
expect(row.summary).toEqual({
id: 7,
content_type: 'venues',
first_field: { key: 'name', type: 'text', value: 'Hall' },
})
})
it('falls back to an empty text summary when the type has no fields', () => {
const row = relationRowOf(entry(3, {}), 'venues', [])
expect(row.summary.first_field).toEqual({ key: '', type: 'text', value: null })
expect(row.detail).toBe('')
})
it('uses null when the first field is absent from the data', () => {
const row = relationRowOf(entry(3, {}), 'venues', [field('name', 'text')])
expect(row.summary.first_field).toEqual({ key: 'name', type: 'text', value: null })
})
it('joins at most the first detail fields and skips empty ones', () => {
const fields = [
field('name', 'text'),
field('city', 'text'),
field('country', 'text'),
field('zip', 'text'),
]
const row = relationRowOf(
entry(1, { name: 'Hall', city: 'Paris', country: 'France', zip: '75000' }),
'venues',
fields,
)
expect(ROW_DETAIL_FIELDS).toBe(2)
expect(row.detail).toBe('Paris · France')
const sparse = relationRowOf(
entry(1, { name: 'Hall', city: '', country: 'France', zip: '75000' }),
'venues',
fields,
)
expect(sparse.detail).toBe('France')
})
})
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import {
inverseNoun,
inverseSide,
kindFromInverseSide,
kindFromOwnerSide,
kindSentence,
ownerNoun,
ownerSide,
relationNames,
relationSample,
singularOf,
} from '@/lib/relationSentences'
import { RELATION_KINDS } from '@/lib/relationField'
const names = relationNames('Events', 'Venues', 'Venue')
describe('the names of both sides', () => {
it('draws the singular from the type name and the related singular from the field label', () => {
expect(names).toEqual({
ownerSingular: 'Event',
ownerPlural: 'Events',
relatedSingular: 'Venue',
relatedPlural: 'Venues',
})
})
it('removes only one final s and keeps one-letter names', () => {
expect(singularOf('Press mentions')).toBe('Press mention')
expect(singularOf('Glass')).toBe('Glas')
expect(singularOf('S')).toBe('S')
})
it('falls back when the related type is not chosen yet', () => {
expect(relationNames('Events', '', 'Place').relatedPlural).toBe('Places')
expect(relationNames('Events', 'Places', '').relatedSingular).toBe('Place')
expect(relationNames('', '', '').ownerSingular).toBe('Entry')
})
})
describe('the sentences of the cards', () => {
it.each([
['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.'],
] as const)('reads %s as the design says', (kind, sentence) => {
expect(kindSentence(kind, names)).toBe(sentence)
})
})
describe('the preview', () => {
it.each([
['one_to_one', 'one', 'Venue', 'one', 'Event'],
['one_to_many', 'one', 'Venue', 'many', 'Events'],
['many_to_many', 'many', 'Venues', 'many', 'Events'],
] as const)('follows %s', (kind, owner, ownerWord, inverse, inverseWord) => {
expect(ownerSide(kind)).toBe(owner)
expect(ownerNoun(kind, names)).toBe(ownerWord)
expect(inverseSide(kind)).toBe(inverse)
expect(inverseNoun(kind, names)).toBe(inverseWord)
})
it('maps the selects of the sentences back to a kind', () => {
expect(kindFromOwnerSide('many', 'one_to_one')).toBe('many_to_many')
expect(kindFromOwnerSide('one', 'many_to_many')).toBe('one_to_many')
expect(kindFromOwnerSide('one', 'one_to_one')).toBe('one_to_one')
expect(kindFromInverseSide('one', 'many_to_many')).toBe('one_to_one')
expect(kindFromInverseSide('many', 'one_to_one')).toBe('one_to_many')
expect(kindFromInverseSide('many', 'many_to_many')).toBe('many_to_many')
})
it('keeps every kind stable through its own sides', () => {
for (const kind of RELATION_KINDS) {
expect(kindFromOwnerSide(ownerSide(kind), kind)).toBe(kind)
expect(kindFromInverseSide(inverseSide(kind), kind)).toBe(kind)
}
})
it('draws the example chips of the board', () => {
expect(relationSample('one_to_many')).toEqual({
ownerChips: ['Rue Verte hall'],
ownerMore: 0,
relatedChips: ['Open day', 'Lime plaster workshop'],
relatedMore: 1,
})
expect(relationSample('one_to_one').relatedChips).toEqual(['Open day'])
expect(relationSample('many_to_many').ownerChips).toHaveLength(2)
})
})
@@ -79,6 +79,7 @@ describe('create payload', () => {
min: null, min: null,
max: 80, max: 80,
options: [], options: [],
relation: null,
}, },
{ {
key: 'starts_on', key: 'starts_on',
@@ -90,6 +91,7 @@ describe('create payload', () => {
min: '2026-01-01', min: '2026-01-01',
max: '2027-12-31', max: '2027-12-31',
options: [], options: [],
relation: null,
}, },
{ {
key: 'online', key: 'online',
@@ -101,6 +103,7 @@ describe('create payload', () => {
min: null, min: null,
max: null, max: null,
options: [], options: [],
relation: null,
}, },
{ {
key: 'capacity', key: 'capacity',
@@ -112,6 +115,7 @@ describe('create payload', () => {
min: 1, min: 1,
max: 5000, max: 5000,
options: [], options: [],
relation: null,
}, },
{ {
key: 'category', key: 'category',
@@ -123,6 +127,7 @@ describe('create payload', () => {
min: null, min: null,
max: null, max: null,
options: ['Talk', 'Workshop', 'Meetup'], options: ['Talk', 'Workshop', 'Meetup'],
relation: null,
}, },
{ {
key: 'description', key: 'description',
@@ -134,12 +139,13 @@ describe('create payload', () => {
min: null, min: null,
max: 5000, max: 5000,
options: [], options: [],
relation: null,
}, },
], ],
}) })
}) })
it('has no icon key and exactly the nine field keys', () => { it('has no icon key and exactly the ten field keys', () => {
const body = buildPayload(EVENTS) const body = buildPayload(EVENTS)
expect(Object.keys(body).sort()).toEqual(['fields', 'key', 'name']) expect(Object.keys(body).sort()).toEqual(['fields', 'key', 'name'])
@@ -151,6 +157,7 @@ describe('create payload', () => {
'max', 'max',
'min', 'min',
'options', 'options',
'relation',
'required', 'required',
'type', 'type',
'unique', 'unique',
+1
View File
@@ -9,6 +9,7 @@ export function apiField(label: string, key: string, type: string, rest: object
min: null, min: null,
max: null, max: null,
options: null, options: null,
relation: null,
...rest, ...rest,
} }
} }
+3
View File
@@ -1,4 +1,5 @@
import type { MediaMap } from '@/lib/mediaField' import type { MediaMap } from '@/lib/mediaField'
import type { RelationDefinition, RelationMap } from '@/lib/relationField'
export interface BuiltinField { export interface BuiltinField {
key: string key: string
@@ -10,6 +11,7 @@ export interface BuiltinField {
min: number | string | null min: number | string | null
max: number | string | null max: number | string | null
options: unknown[] | null options: unknown[] | null
relation?: RelationDefinition | null
} }
export interface ContentType { export interface ContentType {
@@ -27,6 +29,7 @@ export interface ContentEntry {
content_type: string content_type: string
data: Record<string, unknown> data: Record<string, unknown>
media?: MediaMap media?: MediaMap
relations?: RelationMap
created_by: number created_by: number
updated_by: number updated_by: number
created_at: string created_at: string
+64 -1
View File
@@ -3,19 +3,30 @@ import type { BuiltinField } from '@/lib/contentTypes'
import { fieldTypeSpec, type FieldCellKind } from '@/lib/fieldTypes' import { fieldTypeSpec, type FieldCellKind } from '@/lib/fieldTypes'
import { formatDayValue } from '@/lib/entryValue' import { formatDayValue } from '@/lib/entryValue'
import { isMissingImage, type MediaSummary } from '@/lib/mediaField' import { isMissingImage, type MediaSummary } from '@/lib/mediaField'
import { isMissingRelation, relationIdsOf, type RelationSummary } from '@/lib/relationField'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate' import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
export { formatDayValue } export { formatDayValue }
export type CellKind = 'empty' | FieldCellKind export type CellKind = 'empty' | FieldCellKind
export interface RelationChip {
id: number
label: string
missing: boolean
}
export interface CellView { export interface CellView {
kind: CellKind kind: CellKind
text: string text: string
yes?: boolean yes?: boolean
image?: MediaSummary image?: MediaSummary
chips?: RelationChip[]
more?: number
} }
export const RELATION_CHIPS_SHOWN = 2
export const EMPTY_TEXT = '–' export const EMPTY_TEXT = '–'
const EMPTY: CellView = { kind: 'empty', text: '' } const EMPTY: CellView = { kind: 'empty', text: '' }
@@ -48,7 +59,57 @@ function thumbnailCell(value: unknown, image: MediaSummary | undefined): CellVie
} }
} }
export function describeCell(field: BuiltinField, value: unknown, image?: MediaSummary): CellView { function summaryField(first: { key: string; type: string }): BuiltinField {
return {
key: first.key,
label: '',
type: first.type,
required: false,
unique: false,
default: null,
min: null,
max: null,
options: null,
}
}
export function relationLabel(summary: RelationSummary) {
if (isMissingRelation(summary)) {
return t('Entry no longer exists')
}
const first = summary.first_field
const cell = describeCell(summaryField(first), first.value)
return cell.kind === 'empty' || cell.kind === 'thumbnail' || cell.kind === 'relation'
? t('Entry {0}', [summary.id])
: plainCellText(cell)
}
function relationCell(value: unknown, summaries: RelationSummary[]): CellView {
const ids = relationIdsOf(value)
if (ids.length === 0) {
return EMPTY
}
const chips = ids.map((id): RelationChip => {
const summary = summaries.find((candidate) => candidate.id === id) ?? {
id,
missing: true as const,
}
return { id, label: relationLabel(summary), missing: isMissingRelation(summary) }
})
return {
kind: 'relation',
text: chips.map((chip) => chip.label).join(', '),
chips: chips.slice(0, RELATION_CHIPS_SHOWN),
more: Math.max(0, chips.length - RELATION_CHIPS_SHOWN),
}
}
export function describeCell(
field: BuiltinField,
value: unknown,
image?: MediaSummary,
relations: RelationSummary[] = [],
): CellView {
if (value === undefined || value === null || value === '') { if (value === undefined || value === null || value === '') {
return EMPTY return EMPTY
} }
@@ -56,6 +117,8 @@ export function describeCell(field: BuiltinField, value: unknown, image?: MediaS
switch (kind) { switch (kind) {
case 'thumbnail': case 'thumbnail':
return thumbnailCell(value, image) return thumbnailCell(value, image)
case 'relation':
return relationCell(value, relations)
case 'boolean': case 'boolean':
return { kind, text: '', yes: value === true } return { kind, text: '', yes: value === true }
case 'number': case 'number':
+24 -1
View File
@@ -4,8 +4,22 @@ import { rangeHelp, uniqueHelp, type EntryRule } from '@/lib/entryMessages'
import { asBoolean, asNumber, asText, charCount, isBlank, isIsoDay } from '@/lib/entryValue' import { asBoolean, asNumber, asText, charCount, isBlank, isIsoDay } from '@/lib/entryValue'
import type { FieldType } from '@/lib/fieldTypes' import type { FieldType } from '@/lib/fieldTypes'
import { isMediaId, isMissingImage, type MediaSummary } from '@/lib/mediaField' import { isMediaId, isMissingImage, type MediaSummary } from '@/lib/mediaField'
import {
relationInitial,
relationPayload,
relationViolation,
type RelationSummary,
} from '@/lib/relationField'
export type FieldControl = 'input' | 'markdown' | 'number' | 'switch' | 'date' | 'select' | 'media' export type FieldControl =
| 'input'
| 'markdown'
| 'number'
| 'switch'
| 'date'
| 'select'
| 'media'
| 'relation'
export interface FieldCounter { export interface FieldCounter {
text: string text: string
@@ -14,6 +28,7 @@ export interface FieldCounter {
export interface EntryContext { export interface EntryContext {
image: MediaSummary | null image: MediaSummary | null
relations: RelationSummary[]
} }
export interface EntryEditorSpec { export interface EntryEditorSpec {
@@ -197,4 +212,12 @@ export const ENTRY_EDITORS: Record<FieldType, EntryEditorSpec> = {
help: () => t('One image from your library.'), help: () => t('One image from your library.'),
counter: noCounter, counter: noCounter,
}, },
relation: {
control: 'relation',
initial: relationInitial,
validate: relationViolation,
payload: relationPayload,
help: noHelp,
counter: noCounter,
},
} }
+17 -3
View File
@@ -2,9 +2,10 @@ import { t } from '@/i18n'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes' import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { describeCell, plainCellText } from '@/lib/entryCells' import { describeCell, plainCellText } from '@/lib/entryCells'
import { ruleMessage, type EntryRule } from '@/lib/entryMessages' import { ruleMessage, type EntryRule } from '@/lib/entryMessages'
import { isBlank } from '@/lib/entryValue' import { isBlank, sameValue } from '@/lib/entryValue'
import { fieldTypeSpec } from '@/lib/fieldTypes' import { fieldTypeSpec } from '@/lib/fieldTypes'
import { mediaOf, type MediaMap } from '@/lib/mediaField' import { mediaOf, type MediaMap } from '@/lib/mediaField'
import { relationSummariesFor, type RelationSummaries } from '@/lib/relationField'
export type EntryValues = Record<string, unknown> export type EntryValues = Record<string, unknown>
@@ -39,6 +40,17 @@ export function initialImages(fields: BuiltinField[], stored: ContentEntry | nul
return images return images
} }
export function isRelationField(field: BuiltinField) {
return fieldTypeSpec(field.type).editor.control === 'relation'
}
export function initialRelations(
fields: BuiltinField[],
stored: ContentEntry | null,
): RelationSummaries {
return relationSummariesFor(fields, stored, isRelationField)
}
export function buildData(fields: BuiltinField[], values: EntryValues) { export function buildData(fields: BuiltinField[], values: EntryValues) {
return Object.fromEntries( return Object.fromEntries(
fields.map((field) => [ fields.map((field) => [
@@ -56,11 +68,13 @@ export function validateEntry(
fields: BuiltinField[], fields: BuiltinField[],
values: EntryValues, values: EntryValues,
images: MediaMap = {}, images: MediaMap = {},
relations: RelationSummaries = {},
): EntryIssues { ): EntryIssues {
const issues: EntryIssues = {} const issues: EntryIssues = {}
for (const field of fields) { for (const field of fields) {
const rule = fieldTypeSpec(field.type).editor.validate(field, values[field.key], { const rule = fieldTypeSpec(field.type).editor.validate(field, values[field.key], {
image: images[field.key] ?? null, image: images[field.key] ?? null,
relations: relations[field.key] ?? [],
}) })
if (rule) { if (rule) {
issues[field.key] = issueFor(field, rule) issues[field.key] = issueFor(field, rule)
@@ -74,7 +88,7 @@ export function firstIssueKey(fields: BuiltinField[], issues: EntryIssues) {
} }
export function valuesDiffer(fields: BuiltinField[], current: EntryValues, saved: EntryValues) { export function valuesDiffer(fields: BuiltinField[], current: EntryValues, saved: EntryValues) {
return fields.some((field) => !Object.is(current[field.key], saved[field.key])) return fields.some((field) => !sameValue(current[field.key], saved[field.key]))
} }
export function carriesDefault(field: BuiltinField, value: unknown) { export function carriesDefault(field: BuiltinField, value: unknown) {
@@ -91,7 +105,7 @@ export function entryTitle(fields: BuiltinField[], entry: ContentEntry | null) {
return entry ? t('Entry {0}', [entry.id]) : t('New entry') return entry ? t('Entry {0}', [entry.id]) : t('New entry')
} }
const cell = describeCell(first, entry.data[first.key]) const cell = describeCell(first, entry.data[first.key])
return cell.kind === 'empty' || cell.kind === 'thumbnail' return cell.kind === 'empty' || cell.kind === 'thumbnail' || cell.kind === 'relation'
? t('Entry {0}', [entry.id]) ? t('Entry {0}', [entry.id])
: plainCellText(cell) : plainCellText(cell)
} }
+6
View File
@@ -26,6 +26,8 @@ function requiredMessage(field: BuiltinField) {
return t('Choose yes or no for {0}.', [field.label]) return t('Choose yes or no for {0}.', [field.label])
case 'media': case 'media':
return t('Pick an image for {0}.', [field.label]) return t('Pick an image for {0}.', [field.label])
case 'relation':
return t('Choose a {0}.', [field.label])
default: default:
return t('Fill in {0}.', [field.label]) return t('Fill in {0}.', [field.label])
} }
@@ -43,6 +45,8 @@ function typeMessage(field: BuiltinField) {
return t('{0} must be one of the choices.', [field.label]) return t('{0} must be one of the choices.', [field.label])
case 'media': case 'media':
return t('{0} must be an image from the library.', [field.label]) return t('{0} must be an image from the library.', [field.label])
case 'relation':
return t('{0} must point to an existing entry.', [field.label])
default: default:
return t('{0} must be text.', [field.label]) return t('{0} must be text.', [field.label])
} }
@@ -76,6 +80,8 @@ function uniqueMessage(field: BuiltinField) {
return t('Another entry already uses this choice for {0}.', [field.label]) return t('Another entry already uses this choice for {0}.', [field.label])
case 'date': case 'date':
return t('Another entry already uses this date for {0}.', [field.label]) return t('Another entry already uses this date for {0}.', [field.label])
case 'relation':
return t('That {0} is already used by another entry.', [field.label])
default: default:
return t('Another entry already uses this {0}. Enter a different one.', [field.label]) return t('Another entry already uses this {0}. Enter a different one.', [field.label])
} }
+10
View File
@@ -57,3 +57,13 @@ export function freeSentinel(taken: string[], base = '__none__') {
} }
return sentinel return sentinel
} }
export function sameValue(left: unknown, right: unknown) {
if (Array.isArray(left) && Array.isArray(right)) {
return (
left.length === right.length &&
left.every((item, index) => Object.is(item, right[index]))
)
}
return Object.is(left, right)
}
+64 -1
View File
@@ -1,4 +1,4 @@
import { t } from '@/i18n' import { formatNumber, t } from '@/i18n'
import { ENTRY_EDITORS, type EntryEditorSpec } from '@/lib/entryEditors' import { ENTRY_EDITORS, type EntryEditorSpec } from '@/lib/entryEditors'
export const FIELD_TYPES = [ export const FIELD_TYPES = [
@@ -9,6 +9,7 @@ export const FIELD_TYPES = [
'date', 'date',
'select', 'select',
'media', 'media',
'relation',
] as const ] as const
export type FieldType = (typeof FIELD_TYPES)[number] export type FieldType = (typeof FIELD_TYPES)[number]
@@ -21,6 +22,7 @@ export type FieldCellKind =
| 'date' | 'date'
| 'choice' | 'choice'
| 'thumbnail' | 'thumbnail'
| 'relation'
export interface FieldTypeSpec { export interface FieldTypeSpec {
icon: string icon: string
@@ -28,6 +30,7 @@ export interface FieldTypeSpec {
description: () => string description: () => string
cell: FieldCellKind cell: FieldCellKind
narrowColumn: boolean narrowColumn: boolean
wideColumn: boolean
alignEnd: boolean alignEnd: boolean
supportsUnique: boolean supportsUnique: boolean
supportsLimits: boolean supportsLimits: boolean
@@ -35,6 +38,8 @@ export interface FieldTypeSpec {
supportsChoices: boolean supportsChoices: boolean
showsUnsupportedOptions: boolean showsUnsupportedOptions: boolean
builderHint: (() => string) | null builderHint: (() => string) | null
noDefaultCause: ((label: string, total: number) => string) | null
holdsRelation: boolean
pairs: boolean pairs: boolean
editor: EntryEditorSpec editor: EntryEditorSpec
} }
@@ -48,6 +53,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('Short text, like a title or a name'), description: () => t('Short text, like a title or a name'),
cell: 'text', cell: 'text',
narrowColumn: false, narrowColumn: false,
wideColumn: false,
alignEnd: false, alignEnd: false,
supportsUnique: true, supportsUnique: true,
supportsLimits: true, supportsLimits: true,
@@ -55,6 +61,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: false, supportsChoices: false,
showsUnsupportedOptions: true, showsUnsupportedOptions: true,
builderHint: null, builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: false, pairs: false,
editor: ENTRY_EDITORS.text, editor: ENTRY_EDITORS.text,
}, },
@@ -64,6 +72,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('Long text with formatting, written in Markdown'), description: () => t('Long text with formatting, written in Markdown'),
cell: 'richtext', cell: 'richtext',
narrowColumn: false, narrowColumn: false,
wideColumn: false,
alignEnd: false, alignEnd: false,
supportsUnique: false, supportsUnique: false,
supportsLimits: true, supportsLimits: true,
@@ -71,6 +80,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: false, supportsChoices: false,
showsUnsupportedOptions: true, showsUnsupportedOptions: true,
builderHint: null, builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: false, pairs: false,
editor: ENTRY_EDITORS.richtext, editor: ENTRY_EDITORS.richtext,
}, },
@@ -80,6 +91,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('Whole or decimal numbers'), description: () => t('Whole or decimal numbers'),
cell: 'number', cell: 'number',
narrowColumn: true, narrowColumn: true,
wideColumn: false,
alignEnd: true, alignEnd: true,
supportsUnique: true, supportsUnique: true,
supportsLimits: true, supportsLimits: true,
@@ -87,6 +99,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: false, supportsChoices: false,
showsUnsupportedOptions: true, showsUnsupportedOptions: true,
builderHint: null, builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: true, pairs: true,
editor: ENTRY_EDITORS.number, editor: ENTRY_EDITORS.number,
}, },
@@ -96,6 +110,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('An on or off switch'), description: () => t('An on or off switch'),
cell: 'boolean', cell: 'boolean',
narrowColumn: true, narrowColumn: true,
wideColumn: false,
alignEnd: false, alignEnd: false,
supportsUnique: false, supportsUnique: false,
supportsLimits: false, supportsLimits: false,
@@ -103,6 +118,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: false, supportsChoices: false,
showsUnsupportedOptions: true, showsUnsupportedOptions: true,
builderHint: null, builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: true, pairs: true,
editor: ENTRY_EDITORS.boolean, editor: ENTRY_EDITORS.boolean,
}, },
@@ -112,6 +129,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('A calendar day, without a time'), description: () => t('A calendar day, without a time'),
cell: 'date', cell: 'date',
narrowColumn: false, narrowColumn: false,
wideColumn: false,
alignEnd: false, alignEnd: false,
supportsUnique: true, supportsUnique: true,
supportsLimits: true, supportsLimits: true,
@@ -119,6 +137,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: false, supportsChoices: false,
showsUnsupportedOptions: true, showsUnsupportedOptions: true,
builderHint: null, builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: true, pairs: true,
editor: ENTRY_EDITORS.date, editor: ENTRY_EDITORS.date,
}, },
@@ -128,6 +148,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('One option from a list you define'), description: () => t('One option from a list you define'),
cell: 'choice', cell: 'choice',
narrowColumn: false, narrowColumn: false,
wideColumn: false,
alignEnd: false, alignEnd: false,
supportsUnique: true, supportsUnique: true,
supportsLimits: false, supportsLimits: false,
@@ -135,6 +156,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: true, supportsChoices: true,
showsUnsupportedOptions: true, showsUnsupportedOptions: true,
builderHint: null, builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: false, pairs: false,
editor: ENTRY_EDITORS.select, editor: ENTRY_EDITORS.select,
}, },
@@ -144,6 +167,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('One image from your media library'), description: () => t('One image from your media library'),
cell: 'thumbnail', cell: 'thumbnail',
narrowColumn: true, narrowColumn: true,
wideColumn: false,
alignEnd: false, alignEnd: false,
supportsUnique: false, supportsUnique: false,
supportsLimits: false, supportsLimits: false,
@@ -154,9 +178,48 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
t( t(
'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.', 'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.',
), ),
noDefaultCause: (label, total) =>
total === 1
? t(
"A media field can't have a default, so the existing entry would have no image. Make {0} optional to add it.",
[label],
)
: t(
"A media field can't have a default, so the {0} existing entries would have no image. Make {1} optional to add it.",
[formatNumber(total), label],
),
holdsRelation: false,
pairs: false, pairs: false,
editor: ENTRY_EDITORS.media, editor: ENTRY_EDITORS.media,
}, },
relation: {
icon: 'i-lucide-link',
label: () => t('Relation'),
description: () => t("Links to another type's entries"),
cell: 'relation',
narrowColumn: false,
wideColumn: true,
alignEnd: false,
supportsUnique: false,
supportsLimits: false,
supportsDefault: false,
supportsChoices: false,
showsUnsupportedOptions: false,
builderHint: null,
noDefaultCause: (label, total) =>
total === 1
? t(
"A relation can't have a default, so the existing entry would have no {0}. Make {0} optional to add it.",
[label],
)
: t(
"A relation can't have a default, so the {0} existing entries would have no {1}. Make {1} optional to add it.",
[formatNumber(total), label],
),
holdsRelation: true,
pairs: false,
editor: ENTRY_EDITORS.relation,
},
} }
export function isFieldType(type: string): type is FieldType { export function isFieldType(type: string): type is FieldType {
+3 -1
View File
@@ -51,7 +51,9 @@ export function usageEntryTitle(item: MediaUsageItem) {
}, },
first.value, first.value,
) )
return cell.kind === 'empty' || cell.kind === 'thumbnail' ? fallback : plainCellText(cell) return cell.kind === 'empty' || cell.kind === 'thumbnail' || cell.kind === 'relation'
? fallback
: plainCellText(cell)
} }
function toEntry(item: MediaUsageItem): UsageEntry { function toEntry(item: MediaUsageItem): UsageEntry {
+170
View File
@@ -0,0 +1,170 @@
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import type { EntryRule } from '@/lib/entryMessages'
export const RELATION_KINDS = ['one_to_one', 'one_to_many', 'many_to_many'] as const
export type RelationKind = (typeof RELATION_KINDS)[number]
export const DEFAULT_RELATION_KIND: RelationKind = 'one_to_many'
export const RELATION_MAX = 50
export const RELATION_PAGE_SIZE = 10
const RELATION_ID_MAX = 4294967295
export interface RelationDefinition {
content_type: string
kind: RelationKind
}
export interface RelationFirstField {
key: string
type: string
value: unknown
}
export interface RelationTarget {
id: number
content_type: string
first_field: RelationFirstField
}
export interface MissingRelation {
id: number
missing: true
}
export type RelationSummary = RelationTarget | MissingRelation
export type RelationMap = Record<string, RelationSummary | RelationSummary[]>
export type RelationSummaries = Record<string, RelationSummary[]>
export interface RelationContext {
relations: RelationSummary[]
}
export function isMissingRelation(summary: RelationSummary): summary is MissingRelation {
return 'missing' in summary
}
export function isRelationKind(value: unknown): value is RelationKind {
return (RELATION_KINDS as readonly unknown[]).includes(value)
}
export function isRelationId(value: unknown): value is number {
return (
typeof value === 'number' &&
Number.isInteger(value) &&
value >= 1 &&
value <= RELATION_ID_MAX
)
}
export function holdsManyRelations(field: Pick<BuiltinField, 'relation'>) {
return field.relation?.kind === 'many_to_many'
}
export function emptyRelationValue(field: Pick<BuiltinField, 'relation'>) {
return holdsManyRelations(field) ? [] : null
}
export function relationIdsOf(value: unknown): number[] {
if (Array.isArray(value)) {
return value.filter(isRelationId)
}
return isRelationId(value) ? [value] : []
}
export function relationInitial(field: BuiltinField, stored: unknown, creating: boolean) {
if (creating) {
return emptyRelationValue(field)
}
if (holdsManyRelations(field)) {
return Array.isArray(stored) && stored.every(isRelationId) ? [...stored] : []
}
return isRelationId(stored) ? stored : null
}
function hasDuplicates(ids: number[]) {
return new Set(ids).size !== ids.length
}
function onlyMissing(ids: number[], summaries: RelationSummary[]) {
const gone = new Set(summaries.filter(isMissingRelation).map((summary) => summary.id))
return ids.length > 0 && ids.every((id) => gone.has(id))
}
function manyViolation(field: BuiltinField, value: unknown, context?: RelationContext) {
if (value === null || value === undefined) {
return field.required ? 'required' : null
}
if (!Array.isArray(value)) {
return 'type'
}
if (value.length === 0) {
return field.required ? 'required' : null
}
if (value.length > RELATION_MAX || !value.every(isRelationId) || hasDuplicates(value)) {
return 'type'
}
return field.required && onlyMissing(value, context?.relations ?? []) ? 'required' : null
}
function singleViolation(field: BuiltinField, value: unknown, context?: RelationContext) {
if (value === null || value === undefined) {
return field.required ? 'required' : null
}
if (!isRelationId(value)) {
return 'type'
}
return field.required && onlyMissing([value], context?.relations ?? []) ? 'required' : null
}
export function relationViolation(
field: BuiltinField,
value: unknown,
context?: RelationContext,
): EntryRule | null {
return holdsManyRelations(field)
? manyViolation(field, value, context)
: singleViolation(field, value, context)
}
export function relationPayload(field: BuiltinField, value: unknown) {
if (holdsManyRelations(field)) {
return Array.isArray(value) ? [...value] : []
}
return value === undefined ? null : value
}
export function summariesOf(
entry: Pick<ContentEntry, 'relations'> | null | undefined,
key: string,
): RelationSummary[] {
const found = entry?.relations?.[key]
if (found === undefined) {
return []
}
return Array.isArray(found) ? found : [found]
}
export function relationSummariesFor(
fields: BuiltinField[],
entry: ContentEntry | null,
isRelation: (field: BuiltinField) => boolean,
): RelationSummaries {
const result: RelationSummaries = {}
for (const field of fields) {
if (!isRelation(field)) {
continue
}
const stored = relationIdsOf(entry?.data[field.key])
const summaries = summariesOf(entry, field.key).filter((summary) =>
stored.includes(summary.id),
)
if (summaries.length > 0) {
result[field.key] = summaries
}
}
return result
}
+47
View File
@@ -0,0 +1,47 @@
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { describeCell, isEmptyCell, plainCellText, relationLabel } from '@/lib/entryCells'
import type { RelationTarget } from '@/lib/relationField'
export const ROW_DETAIL_FIELDS = 2
const DETAIL_SEPARATOR = ' · '
const UNKNOWN_FIELD_TYPE = 'text'
const NO_TEXT_KINDS = ['thumbnail', 'relation']
export interface RelationRow {
id: number
title: string
detail: string
summary: RelationTarget
}
function summaryOf(entry: ContentEntry, targetKey: string, fields: BuiltinField[]): RelationTarget {
const first = fields[0]
return {
id: entry.id,
content_type: targetKey,
first_field: {
key: first?.key ?? '',
type: first?.type ?? UNKNOWN_FIELD_TYPE,
value: first ? (entry.data[first.key] ?? null) : null,
},
}
}
function detailOf(entry: ContentEntry, fields: BuiltinField[]) {
return fields
.slice(1, 1 + ROW_DETAIL_FIELDS)
.map((field) => describeCell(field, entry.data[field.key]))
.filter((cell) => !isEmptyCell(cell) && !NO_TEXT_KINDS.includes(cell.kind))
.map(plainCellText)
.join(DETAIL_SEPARATOR)
}
export function relationRowOf(
entry: ContentEntry,
targetKey: string,
fields: BuiltinField[],
): RelationRow {
const summary = summaryOf(entry, targetKey, fields)
return { id: entry.id, title: relationLabel(summary), detail: detailOf(entry, fields), summary }
}
+108
View File
@@ -0,0 +1,108 @@
import { t } from '@/i18n'
import type { RelationKind } from '@/lib/relationField'
export type RelationSide = 'one' | 'many'
export interface RelationNames {
ownerSingular: string
ownerPlural: string
relatedSingular: string
relatedPlural: string
}
export interface RelationSample {
ownerChips: string[]
ownerMore: number
relatedChips: string[]
relatedMore: number
}
const FINAL_PLURAL = /s$/i
const SAMPLE_VENUES = ['Rue Verte hall', 'Rue Verte yard']
const SAMPLE_EVENTS = ['Open day', 'Lime plaster workshop']
const SAMPLE_EVENTS_MORE = 1
export function singularOf(name: string) {
return name.length > 1 ? name.replace(FINAL_PLURAL, '') : name
}
export function relationNames(
ownerName: string,
relatedName: string,
fieldLabel: string,
): RelationNames {
const ownerPlural = ownerName.trim() || t('Entries')
const ownerSingular = ownerName.trim() ? singularOf(ownerPlural) : t('Entry')
const relatedSingular = fieldLabel.trim() || singularOf(relatedName.trim()) || t('Entry')
return {
ownerSingular,
ownerPlural,
relatedSingular,
relatedPlural: relatedName.trim() || `${relatedSingular}s`,
}
}
export function ownerSide(kind: RelationKind): RelationSide {
return kind === 'many_to_many' ? 'many' : 'one'
}
export function inverseSide(kind: RelationKind): RelationSide {
return kind === 'one_to_one' ? 'one' : 'many'
}
export function kindFromOwnerSide(side: RelationSide, kind: RelationKind): RelationKind {
if (side === 'many') {
return 'many_to_many'
}
return inverseSide(kind) === 'one' ? 'one_to_one' : 'one_to_many'
}
export function kindFromInverseSide(side: RelationSide, kind: RelationKind): RelationKind {
if (side === 'one') {
return 'one_to_one'
}
return ownerSide(kind) === 'many' ? 'many_to_many' : 'one_to_many'
}
export function kindSentence(kind: RelationKind, names: RelationNames) {
const { ownerSingular, ownerPlural, relatedSingular, relatedPlural } = names
switch (kind) {
case 'one_to_one':
return t('Each {0} has one {1}, and each {1} has one {0}.', [
ownerSingular,
relatedSingular,
])
case 'one_to_many':
return t('Each {0} has one {1}. A {1} has many {2}.', [
ownerSingular,
relatedSingular,
ownerPlural,
])
default:
return t('Each {0} has many {1}, and each {2} has many {3}.', [
ownerSingular,
relatedPlural,
relatedSingular,
ownerPlural,
])
}
}
export function ownerNoun(kind: RelationKind, names: RelationNames) {
return ownerSide(kind) === 'many' ? names.relatedPlural : names.relatedSingular
}
export function inverseNoun(kind: RelationKind, names: RelationNames) {
return inverseSide(kind) === 'many' ? names.ownerPlural : names.ownerSingular
}
export function relationSample(kind: RelationKind): RelationSample {
const many = inverseSide(kind) === 'many'
return {
ownerChips: ownerSide(kind) === 'many' ? SAMPLE_VENUES : SAMPLE_VENUES.slice(0, 1),
ownerMore: 0,
relatedChips: many ? SAMPLE_EVENTS : SAMPLE_EVENTS.slice(0, 1),
relatedMore: many ? SAMPLE_EVENTS_MORE : 0,
}
}
+15 -18
View File
@@ -19,7 +19,7 @@ export interface FieldChange {
label: string label: string
notes: ChangeNote[] notes: ChangeNote[]
flag: ChangeFlag | null flag: ChangeFlag | null
mediaAdd?: boolean noDefaultAdd?: boolean
} }
type Bound = number | string | null type Bound = number | string | null
@@ -151,20 +151,15 @@ function changeNotes(before: PayloadField, after: PayloadField) {
return notes return notes
} }
function mediaAddNote(field: PayloadField, total: number) { function noDefaultAddNote(field: PayloadField, total: number) {
const summary = `${fieldTypeSpec(field.type).label()}, ${field.required ? t('required') : t('optional')}.` const spec = fieldTypeSpec(field.type)
const summary = `${spec.label()}, ${field.required ? t('required') : t('optional')}.`
if (field.required) { if (field.required) {
const cause = return note(
total === 1 `${summary} ${spec.noDefaultCause?.(field.label, total) ?? ''}`.trim(),
? t( '',
"A media field can't have a default, so the existing entry would have no image. Make {0} optional to add it.", true,
[field.label], )
)
: t(
"A media field can't have a default, so the {0} existing entries would have no image. Make {1} optional to add it.",
[formatNumber(total), field.label],
)
return note(`${summary} ${cause}`, '', true)
} }
if (total <= 0) { if (total <= 0) {
return note(summary) return note(summary)
@@ -178,7 +173,7 @@ function mediaAddNote(field: PayloadField, total: number) {
function addNote(field: PayloadField, total: number) { function addNote(field: PayloadField, total: number) {
if (!fieldTypeSpec(field.type).supportsDefault) { if (!fieldTypeSpec(field.type).supportsDefault) {
return mediaAddNote(field, total) return noDefaultAddNote(field, total)
} }
const parts = [ const parts = [
fieldTypeSpec(field.type).label(), fieldTypeSpec(field.type).label(),
@@ -233,7 +228,7 @@ export function diffFields(
label: after.label, label: after.label,
notes, notes,
flag: addFlag(after, notes, total), flag: addFlag(after, notes, total),
...(fieldTypeSpec(after.type).supportsDefault ? {} : { mediaAdd: true }), ...(fieldTypeSpec(after.type).supportsDefault ? {} : { noDefaultAdd: true }),
}) })
} else if (fieldSignature(previous) !== fieldSignature(after)) { } else if (fieldSignature(previous) !== fieldSignature(after)) {
const notes = changeNotes(previous, after) const notes = changeNotes(previous, after)
@@ -287,8 +282,10 @@ export function flaggedChanges(changes: readonly FieldChange[]) {
return changes.filter((change) => change.flag === 'mayNotFit') return changes.filter((change) => change.flag === 'mayNotFit')
} }
export function isMediaOnlyClean(changes: readonly FieldChange[]) { export function isNoDefaultOnlyClean(changes: readonly FieldChange[]) {
return changes.length > 0 && changes.every((change) => change.mediaAdd && change.flag === null) return (
changes.length > 0 && changes.every((change) => change.noDefaultAdd && change.flag === null)
)
} }
export function hasRefusedChange(changes: readonly FieldChange[]) { export function hasRefusedChange(changes: readonly FieldChange[]) {
+3
View File
@@ -7,6 +7,7 @@ import {
type FieldType, type FieldType,
} from '@/lib/schemaBuilderModel' } from '@/lib/schemaBuilderModel'
import { payloadField, type PayloadField } from '@/lib/schemaBuilderPayload' import { payloadField, type PayloadField } from '@/lib/schemaBuilderPayload'
import { DEFAULT_RELATION_KIND } from '@/lib/relationField'
const DATE_LENGTH = 10 const DATE_LENGTH = 10
@@ -42,6 +43,8 @@ export function fieldFromApi(source: BuiltinField, uid: number): BuilderField {
min: bound(source.min), min: bound(source.min),
max: bound(source.max), max: bound(source.max),
options: type === 'select' ? asOptions(source.options) : [], options: type === 'select' ? asOptions(source.options) : [],
relationType: source.relation?.content_type ?? '',
relationKind: source.relation?.kind ?? DEFAULT_RELATION_KIND,
saved: true, saved: true,
}) })
} }
+9
View File
@@ -1,4 +1,5 @@
import { FIELD_TYPE_SPECS, FIELD_TYPES, type FieldType } from '@/lib/fieldTypes' import { FIELD_TYPE_SPECS, FIELD_TYPES, type FieldType } from '@/lib/fieldTypes'
import { DEFAULT_RELATION_KIND, type RelationKind } from '@/lib/relationField'
export { FIELD_TYPES, type FieldType } export { FIELD_TYPES, type FieldType }
export type KeyMode = 'auto' | 'manual' export type KeyMode = 'auto' | 'manual'
@@ -39,6 +40,8 @@ export interface BuilderField {
min: string min: string
max: string max: string
options: string[] options: string[]
relationType: string
relationKind: RelationKind
saved: boolean saved: boolean
} }
@@ -71,6 +74,8 @@ export function createField(uid: number, overrides: Partial<BuilderField> = {}):
min: '', min: '',
max: '', max: '',
options: [], options: [],
relationType: '',
relationKind: DEFAULT_RELATION_KIND,
saved: false, saved: false,
...overrides, ...overrides,
} }
@@ -111,6 +116,10 @@ export function supportsChoices(type: FieldType) {
return FIELD_TYPE_SPECS[type].supportsChoices return FIELD_TYPE_SPECS[type].supportsChoices
} }
export function holdsRelation(type: FieldType) {
return FIELD_TYPE_SPECS[type].holdsRelation
}
export function supportsLimits(type: FieldType) { export function supportsLimits(type: FieldType) {
return FIELD_TYPE_SPECS[type].supportsLimits return FIELD_TYPE_SPECS[type].supportsLimits
} }
+10
View File
@@ -1,5 +1,6 @@
import { import {
effectiveDefault, effectiveDefault,
holdsRelation,
isUnique, isUnique,
supportsChoices, supportsChoices,
supportsLimits, supportsLimits,
@@ -7,6 +8,7 @@ import {
type BuilderField, type BuilderField,
type BuilderForm, type BuilderForm,
} from '@/lib/schemaBuilderModel' } from '@/lib/schemaBuilderModel'
import type { RelationDefinition } from '@/lib/relationField'
export interface PayloadField { export interface PayloadField {
key: string key: string
@@ -18,6 +20,7 @@ export interface PayloadField {
min: number | string | null min: number | string | null
max: number | string | null max: number | string | null
options: string[] options: string[]
relation: RelationDefinition | null
} }
export interface ContentTypePayload { export interface ContentTypePayload {
@@ -50,6 +53,12 @@ export interface UpdatePayload {
fields: PayloadField[] fields: PayloadField[]
} }
function relationFor(field: BuilderField): RelationDefinition | null {
return holdsRelation(field.type)
? { content_type: field.relationType, kind: field.relationKind }
: null
}
export function payloadField(field: BuilderField): PayloadField { export function payloadField(field: BuilderField): PayloadField {
return { return {
key: field.key, key: field.key,
@@ -61,6 +70,7 @@ export function payloadField(field: BuilderField): PayloadField {
min: limit(field, field.min), min: limit(field, field.min),
max: limit(field, field.max), max: limit(field, field.max),
options: supportsChoices(field.type) ? trimmedOptions(field) : [], options: supportsChoices(field.type) ? trimmedOptions(field) : [],
relation: relationFor(field),
} }
} }
+4 -2
View File
@@ -30,7 +30,7 @@ export function previewRoutes(key: string) {
] ]
} }
export function sampleValue(field: BuilderField): string | number | boolean | null { export function sampleValue(field: BuilderField): string | number | boolean | null | never[] {
const given = effectiveDefault(field) const given = effectiveDefault(field)
switch (field.type) { switch (field.type) {
case 'richtext': case 'richtext':
@@ -49,6 +49,8 @@ export function sampleValue(field: BuilderField): string | number | boolean | nu
return given === '' ? SAMPLE_DATE : given return given === '' ? SAMPLE_DATE : given
case 'media': case 'media':
return null return null
case 'relation':
return field.relationKind === 'many_to_many' ? [] : null
case 'select': { case 'select': {
const choices = trimmedOptions(field).filter((option) => option !== '') const choices = trimmedOptions(field).filter((option) => option !== '')
return given !== '' ? given : (choices[0] ?? null) return given !== '' ? given : (choices[0] ?? null)
@@ -59,7 +61,7 @@ export function sampleValue(field: BuilderField): string | number | boolean | nu
} }
export function sampleEntry(key: string, fields: readonly BuilderField[]) { export function sampleEntry(key: string, fields: readonly BuilderField[]) {
const data: Record<string, string | number | boolean | null> = {} const data: Record<string, string | number | boolean | null | never[]> = {}
for (const field of fields) { for (const field of fields) {
if (isValidFieldKey(field.key)) { if (isValidFieldKey(field.key)) {
data[field.key] = sampleValue(field) data[field.key] = sampleValue(field)
+2 -1
View File
@@ -1,7 +1,7 @@
import type { FieldErrors } from '@/lib/schemaBuilderValidation' import type { FieldErrors } from '@/lib/schemaBuilderValidation'
export type TypeTouch = 'name' | 'key' | 'fields' export type TypeTouch = 'name' | 'key' | 'fields'
export type FieldTouch = 'label' | 'key' | 'min' | 'max' | 'default' | 'choices' export type FieldTouch = 'label' | 'key' | 'min' | 'max' | 'default' | 'choices' | 'relation'
export function typeTouchKey(name: TypeTouch) { export function typeTouchKey(name: TypeTouch) {
return `type:${name}` return `type:${name}`
@@ -19,4 +19,5 @@ export const FIELD_ERROR_TOUCH: readonly (readonly [keyof FieldErrors, FieldTouc
['default', 'default'], ['default', 'default'],
['options', 'choices'], ['options', 'choices'],
['choices', 'choices'], ['choices', 'choices'],
['relation', 'relation'],
] ]
+5
View File
@@ -2,6 +2,7 @@ import { t } from '@/i18n'
import { import {
BUILDER_LIMITS, BUILDER_LIMITS,
RESERVED_TYPE_KEYS, RESERVED_TYPE_KEYS,
holdsRelation,
trimmedOptions, trimmedOptions,
type BuilderField, type BuilderField,
type BuilderForm, type BuilderForm,
@@ -25,6 +26,7 @@ export interface FieldErrors {
default?: string default?: string
options?: string options?: string
choices?: Record<number, string> choices?: Record<number, string>
relation?: string
} }
export interface FormErrors { export interface FormErrors {
@@ -190,6 +192,9 @@ export function validateField(field: BuilderField, earlierKeys: readonly string[
if (key) { if (key) {
errors.key = key errors.key = key
} }
if (holdsRelation(field.type) && field.relationType === '') {
errors.relation = t('Choose a related content type.')
}
if (field.type === 'text' || field.type === 'richtext') { if (field.type === 'text' || field.type === 'richtext') {
textLimits(field, errors) textLimits(field, errors)
} else if (field.type === 'number') { } else if (field.type === 'number') {
+2 -2
View File
@@ -1,5 +1,5 @@
export type DeleteStage = 'closed' | 'blocked' | 'confirm' | 'failed' | 'conflict' export type DeleteStage = 'closed' | 'blocked' | 'confirm' | 'failed' | 'conflict' | 'referenced'
export function isConfirmStage(stage: DeleteStage) { export function isConfirmStage(stage: DeleteStage) {
return stage === 'confirm' || stage === 'failed' return stage === 'confirm' || stage === 'failed' || stage === 'referenced'
} }
@@ -0,0 +1,243 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import type { RelationSummary } from '@/lib/relationField'
import {
calls,
eventEntries,
installSelectPolyfills,
listPage,
mountList,
text,
unmountAll,
} from './contentTestKit'
import { drawsIcon } from './mediaFieldKit'
import { eventsFields } from './schemaTestKit'
import { apiField } from '@/lib/__tests__/schemaFixtures'
import { relationField, summaryFor } from './relationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeAll(installSelectPolyfills)
afterEach(async () => {
void router
await unmountAll()
})
const KEY = 'renewcms-content-columns-events'
const COLUMNS = ['title', 'starts_on', 'venue', 'speakers']
const MISSING: RelationSummary = { id: 9, missing: true }
type Relations = Record<string, RelationSummary | RelationSummary[]>
const RELATIONS: Record<number, [number | null, number[], Relations]> = {
42: [3, [11, 12, 13, 14], {}],
41: [null, [12], {}],
40: [4, [], {}],
}
const eventType = () => ({
id: 1,
key: 'events',
name: 'Events',
icon: 'files',
created_at: '2026-09-01T09:30:00Z',
updated_at: '2026-10-15T09:30:00Z',
fields: [
eventsFields[0]!,
eventsFields[1]!,
relationField('Venue', 'venue', 'venues', 'one_to_many'),
relationField('Speakers', 'speakers', 'speakers', 'many_to_many'),
],
})
function itemOf(id: number, overrides: { relations?: Relations; venue?: number | null } = {}) {
const [venue, speakers] = RELATIONS[id]!
const base = eventEntries.find((entry) => entry.id === id)!
const shownVenue = overrides.venue === undefined ? venue : overrides.venue
return {
...base,
data: { ...base.data, venue: shownVenue, speakers },
relations: overrides.relations ?? {
...(shownVenue === null ? {} : { venue: summaryFor('venues', shownVenue) }),
...(speakers.length === 0
? {}
: { speakers: speakers.map((speaker) => summaryFor('speakers', speaker)) }),
},
}
}
function mountRelations(
width?: number,
items = [itemOf(42), itemOf(41), itemOf(40)],
type = eventType,
columns: string[] = COLUMNS,
) {
return mountList('/content/events', {
width,
before: () => localStorage.setItem(KEY, JSON.stringify(columns)),
type,
content: () => [type()],
list: (_key, query) => listPage(items, items.length, query),
})
}
const headers = () => [...document.body.querySelectorAll('thead th')]
const rowsOf = () => [...document.body.querySelectorAll('tbody tr')]
const cellOf = (row: number, column: number) => rowsOf()[row]!.querySelectorAll('td')[column]!
const chipsIn = (element: Element) => [...element.querySelectorAll('[data-testid="relation-chip"]')]
describe('The relation cell', () => {
it('shows one chip for a single relation', async () => {
await mountRelations()
const chips = chipsIn(cellOf(0, 3))
expect(chips.map(text)).toEqual(['Rue Verte hall'])
expect(chips[0]!.className).toContain('h-5')
expect(chips[0]!.className).toContain('px-1.5')
expect(chips[0]!.className).toContain('bg-(--rc-sand)')
})
it('shows two chips and a muted +2 for four speakers', async () => {
await mountRelations()
expect(chipsIn(cellOf(0, 4)).map(text)).toEqual(['Ines Duarte', 'Jonas Pereira'])
expect(text(cellOf(0, 4))).toBe('Ines Duarte Jonas Pereira +2')
const more = [...cellOf(0, 4).querySelectorAll('span')].find((span) => text(span) === '+2')!
expect(more.className).toContain('text-muted')
})
it('shows the muted dash for no venue and for an empty speakers list', async () => {
await mountRelations()
expect(text(cellOf(1, 3))).toBe('–')
expect(cellOf(1, 3).querySelector('span')!.className).toContain('text-muted')
expect(text(cellOf(2, 4))).toBe('–')
expect(text(cellOf(1, 4))).toBe('Jonas Pereira')
expect(text(cellOf(2, 3))).toBe('Rue Verte yard')
})
it('shows a dashed chip for a missing venue', async () => {
await mountRelations(undefined, [
itemOf(42, { venue: 9, relations: { venue: MISSING } }),
itemOf(41),
itemOf(40),
])
const chip = chipsIn(cellOf(0, 3))[0]!
expect(text(chip)).toBe('Entry no longer exists')
expect(chip.getAttribute('data-missing')).toBe('true')
expect(chip.className).toContain('border-dashed')
})
it('reads each item own relations with no extra request', async () => {
await mountRelations()
expect(calls.filter((url) => url.includes('/venues') || url.includes('/speakers'))).toEqual(
[],
)
})
})
describe('The grid of the board', () => {
it('lists the relation headers with the wide column and no sort', async () => {
await mountRelations()
expect(headers().map(text).slice(0, 5)).toEqual([
'ID',
'Title',
'Starts on',
'Venue',
'Speakers',
])
for (const index of [3, 4]) {
expect(headers()[index]!.className).toContain('w-50')
expect(headers()[index]!.querySelector('svg')).toBeNull()
expect(headers()[index]!.hasAttribute('aria-sort')).toBe(false)
}
})
it('keeps rows 48 px high', async () => {
await mountRelations()
expect(cellOf(0, 0).className).toContain('h-12')
})
})
describe('The mobile card', () => {
it('lists the title, then a labelled line for Venue and Speakers', async () => {
await mountRelations(MOBILE_WIDTH)
const cards = [...document.body.querySelectorAll('[data-testid="entry-card"]')]
const lines = [...cards[0]!.querySelectorAll('[data-testid="entry-card-relation"]')]
expect(lines.map(text)).toEqual([
'Venue Rue Verte hall',
'Speakers Ines Duarte Jonas Pereira +2',
])
expect(text(cards[1]!.querySelectorAll('[data-testid="entry-card-relation"]')[0])).toBe(
'Venue –',
)
expect(text(cards[2]!.querySelectorAll('[data-testid="entry-card-relation"]')[1])).toBe(
'Speakers –',
)
})
})
describe('The columns menu', () => {
it('lists Venue and Speakers with the link icon and their keys', async () => {
await mountRelations()
const toggle = [...document.body.querySelectorAll('button')].find(
(button) =>
text(button) === 'Columns' || button.getAttribute('aria-label') === 'Columns',
)!
toggle.click()
await vi.waitFor(() =>
expect(document.body.querySelectorAll('[data-testid="column-row"]').length).toBe(4),
)
const rows = [...document.body.querySelectorAll('[data-testid="column-row"]')]
expect(rows.map(text).slice(2)).toEqual(['Venue venue', 'Speakers speakers'])
expect(drawsIcon(rows[2]!, 'link')).toBe(true)
expect(drawsIcon(rows[3]!, 'link')).toBe(true)
})
})
describe('A relation as the first column', () => {
it('keeps the link to the editor around the chips', async () => {
const type = () => {
const [, , venue, speakers] = eventType().fields
return { ...eventType(), fields: [venue!, speakers!, eventsFields[0]!] }
}
await mountRelations(undefined, undefined, type, ['venue', 'speakers', 'title'])
const link = rowsOf()[0]!.querySelector<HTMLAnchorElement>('[data-testid="entry-link"]')!
expect(chipsIn(link).map(text)).toEqual(['Rue Verte hall'])
expect(link.getAttribute('href')).toBe('/content/events/42')
})
it('reads the labels joined by a comma as plain text on the card', async () => {
const type = () => {
const [, , venue, speakers] = eventType().fields
return { ...eventType(), fields: [speakers!, venue!, eventsFields[0]!] }
}
await mountRelations(MOBILE_WIDTH, undefined, type, ['speakers', 'venue', 'title'])
const titles = [
...document.body.querySelectorAll('[data-testid="entry-card"] .truncate'),
].map(text)
expect(titles[0]).toBe('Ines Duarte, Jonas Pereira, Ana Costa, Luc Moreau')
expect(titles.join('|')).not.toMatch(/\b1[1-4]\b/)
})
})
describe('Fixtures', () => {
it('keeps unrelated field helpers', () => {
expect(apiField('X', 'x', 'text').relation).toBeNull()
})
})
@@ -0,0 +1,563 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
button,
calls,
chip,
entryWrites,
fieldWrapper,
installSelectPolyfills,
save,
setField,
text,
typeInto,
unmountAll,
} from './entryEditorKit'
import {
allTypes,
chipLabels,
entryWithRelations,
liveText,
mountRelation,
openList,
optionLabels,
pickOption,
press,
relationError,
relationInput,
removeButtons,
settleSearch,
summaryFor,
typeSearch,
} from './relationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => {
installSelectPolyfills()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
})
afterEach(unmountAll)
const searches = () => calls.filter((url) => url.includes('/entry?') && !url.endsWith('size=5'))
const sentData = (index = 0) => entryWrites[index]!.body.data as Record<string, unknown>
const fieldKeys = () =>
[...document.body.querySelectorAll<HTMLElement>('[data-entry-field]')].map(
(element) => element.dataset.entryField,
)
describe('Where the controls sit', () => {
it('follows the schema order, full width, each with its label', async () => {
await mountRelation('/content/events/42', { venue: 3, speakers: [11, 12] })
expect(fieldKeys().slice(-2)).toEqual(['venue', 'speakers'])
for (const key of ['venue', 'speakers']) {
const row = fieldWrapper(key).parentElement!
expect(row.children).toHaveLength(1)
expect(row.className).not.toContain('md:grid-cols-2')
}
expect(text(fieldWrapper('venue').querySelector('label'))).toBe('Venue')
expect(text(fieldWrapper('speakers').querySelector('label'))).toBe('Speakers')
})
it('never pairs with a boolean, number or date field before it', async () => {
await mountRelation('/content/events/new')
const rows = [...document.body.querySelectorAll('#entry-form > div')]
const venueRow = rows.find((row) => row.querySelector('[data-entry-field="venue"]'))!
expect(venueRow.children).toHaveLength(1)
expect(
venueRow.previousElementSibling!.querySelector('[data-entry-field="description"]'),
).not.toBeNull()
})
})
describe('Single relation, empty, focused search', () => {
it('sends only the trimmed, debounced search and lists rows with a second line', async () => {
await mountRelation('/content/events/42')
await typeSearch('venue', ' ru ')
await vi.advanceTimersByTimeAsync(100)
await typeSearch('venue', 'rue ')
await settleSearch(vi)
expect(searches()).toEqual(['/v1/content-type/venues/entry?q=rue&page=1&size=10'])
expect(optionLabels()).toEqual([
'Rue Verte hall Lyon · 120 seats',
'Rue Verte yard Lyon · outdoor',
'Rue du Port studio Nantes · 24 seats',
])
expect(relationInput('venue').getAttribute('placeholder')).toBe('Search to add')
expect(
document.body.querySelector('[role="option"]')!.hasAttribute('data-highlighted'),
).toBe(true)
expect(liveText('venue')).toBe('3 results')
})
it('lists the first page without q when the input is empty', async () => {
await mountRelation('/content/events/42')
await openList('venue', vi)
expect(searches()).toEqual(['/v1/content-type/venues/entry?page=1&size=10'])
expect(optionLabels()).toHaveLength(4)
})
it('never lets a stale answer overwrite a newer one', async () => {
const gates = new Map<string, (value: unknown) => void>()
await mountRelation('/content/events/42', {
search: (_key, query) =>
new Promise((resolve) => gates.set(query.get('q') ?? '', resolve)),
})
const body = (name: string) => ({
items: [{ id: 3, data: { name, city: '', details: '' }, relations: {} }],
total: 1,
page: 1,
size: 10,
})
await typeSearch('venue', 'old')
await settleSearch(vi)
await typeSearch('venue', 'new')
await settleSearch(vi)
gates.get('new')!(body('Fresh'))
await flushPromises()
gates.get('old')!(body('Stale'))
await flushPromises()
expect(optionLabels()).toEqual(['Fresh'])
})
})
describe('Picking a single target', () => {
it('shows one chip, sets the value, dirties the page and announces it', async () => {
await mountRelation('/content/events/42')
await openList('venue', vi, 'rue')
await pickOption('Rue Verte hall')
expect(chipLabels('venue')).toEqual(['Rue Verte hall'])
expect(liveText('venue')).toBe('Venue set to Rue Verte hall.')
expect(chip()!.textContent).toBe('Unsaved changes')
expect(relationInput('venue').getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(relationInput('venue'))
expect(calls.filter((url) => url.includes('/venues/entry/'))).toEqual([])
await save()
expect(sentData().venue).toBe(3)
})
it('removes the chip with its x and says the field was cleared', async () => {
await mountRelation('/content/events/42', { venue: 3 })
expect(chipLabels('venue')).toEqual(['Rue Verte hall'])
removeButtons('venue')[0]!.click()
await flushPromises()
expect(chipLabels('venue')).toEqual([])
expect(liveText('venue')).toBe('Venue cleared.')
await save()
expect(sentData().venue).toBeNull()
})
it('replaces the chip when another row is picked', async () => {
await mountRelation('/content/events/42', { venue: 3 })
await openList('venue', vi, 'rue')
expect(optionLabels()).not.toContain('Rue Verte hall Lyon · 120 seats')
await pickOption('Rue Verte yard')
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
await save()
expect(sentData().venue).toBe(4)
})
})
describe('Many relation', () => {
it('shows the chips, the placeholder and the help line', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
expect(chipLabels('speakers')).toEqual(['Ines Duarte', 'Jonas Pereira'])
expect(relationInput('speakers').getAttribute('placeholder')).toBe('Search to add')
expect(text(fieldWrapper('speakers').querySelector('[data-testid="relation-help"]'))).toBe(
'An event can have many speakers.',
)
})
it('adds at the end, hides picked rows and announces', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
await openList('speakers', vi, 'ana')
expect(optionLabels()).toEqual(['Ana Costa'])
await pickOption('Ana Costa')
expect(chipLabels('speakers')).toEqual(['Ines Duarte', 'Jonas Pereira', 'Ana Costa'])
expect(liveText('speakers')).toBe('Ana Costa added to Speakers.')
await save()
expect(sentData().speakers).toEqual([11, 12, 13])
})
it('removes a chip with its x or Backspace in the empty input', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
removeButtons('speakers')[0]!.click()
await flushPromises()
expect(chipLabels('speakers')).toEqual(['Jonas Pereira'])
expect(liveText('speakers')).toBe('Ines Duarte removed from Speakers.')
relationInput('speakers').focus()
await press('speakers', 'Backspace')
expect(chipLabels('speakers')).toEqual([])
expect(liveText('speakers')).toBe('Jonas Pereira removed from Speakers.')
})
it('names each x "Remove <name> from <field>"', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
expect(
removeButtons('speakers').map((remove) => remove.getAttribute('aria-label')),
).toEqual(['Remove Ines Duarte from Speakers', 'Remove Jonas Pereira from Speakers'])
expect(removeButtons('speakers')[0]!.hasAttribute('aria-labelledby')).toBe(false)
})
it('cannot add a 51st target', async () => {
const ids = Array.from({ length: 50 }, (_, index) => 100 + index)
const relations = {
speakers: ids.map((id) => ({
id,
content_type: 'speakers',
first_field: { key: 'name', type: 'text', value: `Speaker ${id}` },
})),
}
await mountRelation('/content/events/42', { speakers: ids, relations })
await openList('speakers', vi)
expect(optionLabels()).toEqual([])
expect(text(document.body.querySelector('[data-testid="relation-empty"]'))).toBe(
'You can add up to 50.',
)
})
})
describe('What the control shows and sends', () => {
it('sends null and [] for a create left empty, never absent', async () => {
const wrapper = await mountRelation('/content/events/new')
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
await save()
expect(entryWrites).toHaveLength(1)
expect(entryWrites[0]!.method).toBe('POST')
expect(entryWrites[0]!.url).toBe('/v1/content-type/events/entry')
expect(sentData()).toMatchObject({ venue: null, speakers: [] })
expect('venue' in sentData()).toBe(true)
})
it('sends a JSON number once a venue is picked on create', async () => {
const wrapper = await mountRelation('/content/events/new')
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
await openList('venue', vi, 'rue')
await pickOption('Rue Verte hall')
await save()
expect(sentData().venue).toBe(3)
})
it.each([
['loaded 3 and saved untouched', 3, [], 'Rue Verte hall', 3],
['loaded null and saved untouched', null, [], undefined, null],
])('%s', async (_name, venue, speakers, shown, sent) => {
await mountRelation('/content/events/42', { venue, speakers })
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(chipLabels('venue')[0]).toBe(shown)
expect(sentData().venue).toBe(sent)
})
it('saves a missing venue untouched with the same id', async () => {
await mountRelation('/content/events/42', {
venue: 3,
relations: { venue: { id: 3, missing: true } },
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(sentData().venue).toBe(3)
})
it('adds Jonas Pereira to [11] and removes both from [11, 12]', async () => {
await mountRelation('/content/events/42', { speakers: [11] })
await openList('speakers', vi, 'jonas')
await pickOption('Jonas Pereira')
expect(chipLabels('speakers')).toHaveLength(2)
await save()
expect(sentData().speakers).toEqual([11, 12])
})
it('removes both speakers and sends an empty array', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
removeButtons('speakers')[0]!.click()
await flushPromises()
removeButtons('speakers')[0]!.click()
await flushPromises()
await save()
expect(sentData().speakers).toEqual([])
})
it('rebuilds the control from the answered relations after a save', async () => {
await mountRelation('/content/events/42', { venue: 3 })
await openList('venue', vi, 'rue')
await pickOption('Rue Verte yard')
await save()
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
expect(chip()!.textContent).toContain('Saved')
})
})
describe('Dirty tracking', () => {
it('is saved again after picking 4 then 3', async () => {
await mountRelation('/content/events/42', { venue: 3 })
await openList('venue', vi, 'rue')
await pickOption('Rue Verte yard')
expect(chip()!.textContent).toBe('Unsaved changes')
await openList('venue', vi, 'rue')
await pickOption('Rue Verte hall')
expect(chip()!.textContent).toBe('Saved')
expect(button('Save')?.hasAttribute('disabled')).toBe(true)
})
it('counts the same ids in another order as changed', async () => {
const wrapper = await mountRelation('/content/events/42', { speakers: [11, 12] })
await setField(wrapper, 'speakers', [12, 11])
expect(chip()!.textContent).toBe('Unsaved changes')
await setField(wrapper, 'speakers', [11, 12])
expect(chip()!.textContent).toBe('Saved')
})
})
describe('Required and empty', () => {
it('sends nothing, shows the error, the chip and focuses the control', async () => {
await mountRelation('/content/events/42', { venueField: { required: true } })
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(entryWrites).toHaveLength(0)
expect(relationError('venue')).toBe('Choose a Venue.')
expect(chip()!.textContent).toBe('Not saved')
expect(document.activeElement).toBe(relationInput('venue'))
expect(relationInput('venue').getAttribute('aria-invalid')).toBe('true')
expect(relationInput('venue').getAttribute('aria-required')).toBe('true')
await openList('venue', vi, 'rue')
await pickOption('Rue Verte hall')
expect(relationError('venue')).toBe('')
expect(chip()!.textContent).toBe('Unsaved changes')
})
it('focuses only the first field in schema order that has an error', async () => {
await mountRelation('/content/events/42', {
venueField: { required: true },
speakersField: { required: true },
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(document.activeElement).toBe(relationInput('venue'))
expect(relationError('speakers')).toBe('Choose a Speakers.')
})
})
describe('Server answers for a relation field', () => {
it.each([
[422, 'invalid_entry: venue: required', 'Choose a Venue.'],
[422, 'invalid_entry: venue: type', 'Venue must point to an existing entry.'],
[409, 'invalid_entry: venue: unique', 'That Venue is already used by another entry.'],
])('%i %s shows its copy under the control', async (status, body, message) => {
const { ApiError } = await import('@/composables/useApi')
await mountRelation('/content/events/42', {
venue: 3,
writeEntry: () => {
throw new ApiError(status, undefined, body)
},
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(relationError('venue')).toBe(message)
expect(chip()!.textContent).toBe('Not saved')
expect(document.activeElement).toBe(relationInput('venue'))
expect(document.body.querySelector('[role="alert"]')?.textContent).not.toContain(
'Not saved',
)
})
it('shows the read-only reload alert for unknown_field, as for media', async () => {
const { ApiError } = await import('@/composables/useApi')
await mountRelation('/content/events/42', {
venue: 3,
writeEntry: () => {
throw new ApiError(422, undefined, 'invalid_entry: venue: unknown_field')
},
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(text(document.body)).toContain('The fields of Events changed')
expect(text(document.body)).toContain(
'The Venue field was changed after you opened this form.',
)
expect(button('Reload form')).toBeDefined()
})
})
describe('Saving', () => {
it('dims the form and makes the control inoperable while the PUT is pending', async () => {
let release!: (value: unknown) => void
await mountRelation('/content/events/42', {
venue: 3,
speakers: [11],
writeEntry: () => new Promise((resolve) => (release = resolve)),
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(document.body.querySelector('#entry-form')!.className).toContain('opacity-70')
expect(relationInput('venue').disabled).toBe(true)
expect(button('Saving…')).toBeDefined()
release(entryWithRelations(3, [11]))
await flushPromises()
})
})
describe('Accessibility of the control', () => {
it('points aria-controls at its own listbox, not a foreign one earlier in the page', async () => {
await mountRelation('/content/events/42', { speakers: [11] })
const foreign = document.createElement('div')
foreign.setAttribute('role', 'listbox')
foreign.id = 'foreign-listbox'
document.body.prepend(foreign)
await openList('speakers', vi, 'a')
const own = document.body.querySelector(
'[data-relation-owner] [role="listbox"], [data-relation-owner][role="listbox"]',
)!
expect(own.id).not.toBe('')
expect(relationInput('speakers').getAttribute('aria-controls')).toBe(own.id)
foreign.remove()
})
it('is a labelled combobox on a listbox of options', async () => {
await mountRelation('/content/events/42', { speakers: [11] })
await openList('speakers', vi, 'a')
const input = relationInput('speakers')
const listbox = document.body.querySelector('[role="listbox"]')!
expect(input.getAttribute('aria-expanded')).toBe('true')
expect(input.getAttribute('aria-controls')).toBe(listbox.id)
expect(input.getAttribute('aria-activedescendant')).toBe(
document.body.querySelector('[role="option"]')!.id,
)
expect(fieldWrapper('speakers').querySelector('label')!.getAttribute('for')).toBe(input.id)
const group = fieldWrapper('speakers').querySelector('[role="group"]')!
expect(text(document.getElementById(group.getAttribute('aria-labelledby')!))).toBe(
'Speakers',
)
expect(input.getAttribute('aria-describedby')).toContain('-help')
})
it('has one polite live region', async () => {
await mountRelation('/content/events/42', { venue: 3 })
const live = fieldWrapper('venue').querySelector('[data-testid="relation-live"]')!
expect(live.getAttribute('role')).toBe('status')
expect(live.getAttribute('aria-live')).toBe('polite')
})
})
describe('Keyboard', () => {
it('moves with the arrows, picks with Enter and closes with Escape keeping the text', async () => {
await mountRelation('/content/events/42')
await openList('venue', vi, 'rue')
await press('venue', 'ArrowDown')
const highlighted = () =>
text(document.body.querySelector('[role="option"][data-highlighted]'))
expect(highlighted()).toContain('Rue Verte yard')
await press('venue', 'ArrowUp')
expect(highlighted()).toContain('Rue Verte hall')
await press('venue', 'ArrowDown')
await press('venue', 'Enter')
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
await openList('venue', vi, 'port')
await press('venue', 'Escape')
expect(relationInput('venue').getAttribute('aria-expanded')).toBe('false')
expect(relationInput('venue').value).toBe('port')
})
})
describe('Chips and the keyboard', () => {
it('reaches the chips with ArrowLeft from the empty input', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
relationInput('speakers').focus()
await press('speakers', 'ArrowLeft')
const items = [...fieldWrapper('speakers').querySelectorAll('[data-slot="tagsItem"]')]
expect(items.map((item) => item.getAttribute('data-state'))).toEqual(['inactive', 'active'])
})
it('keeps Backspace for the text when the input is not empty', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
await typeSearch('speakers', 'ab')
await press('speakers', 'Backspace')
expect(chipLabels('speakers')).toEqual(['Ines Duarte', 'Jonas Pereira'])
})
})
describe('Fixtures', () => {
it('keeps the sample data of the board', () => {
expect(allTypes()).toHaveLength(3)
expect(summaryFor('venues', 3).id).toBe(3)
})
})
describe('A relation as the first field', () => {
it('titles the page and the breadcrumb "Entry 42"', async () => {
const types = allTypes()
const [events] = types
const relationFirst = { ...events!, fields: [events!.fields[7]!, events!.fields[0]!] }
await mountRelation('/content/events/42', {
venue: 3,
type: () => relationFirst,
content: () => [relationFirst, ...types.slice(1)],
})
expect(text(document.body.querySelector('h1'))).toBe('Entry 42')
expect(text(document.body)).toContain('Events · Entry 42')
})
})
@@ -0,0 +1,230 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { ApiError, NetworkError } from '@/composables/useApi'
import {
button,
calls,
entryWrites,
fieldWrapper,
installSelectPolyfills,
save,
text,
typeInto,
unmountAll,
} from './entryEditorKit'
import {
chipLabels,
emptyText,
liveText,
mountRelation,
openList,
optionLabels,
pickOption,
relationError,
relationInput,
searchBody,
settleSearch,
typeSearch,
} from './relationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => {
installSelectPolyfills()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
})
afterEach(unmountAll)
const SKELETON_DELAY_MS = 150
const searches = () => calls.filter((url) => url.includes('/venues/entry?'))
const empty = { items: [], total: 0, page: 1, size: 10 }
const skeletons = () => document.body.querySelectorAll('[data-testid="relation-skeleton"]')
function failing(error: unknown) {
return () => {
throw error
}
}
describe('Picker loading', () => {
it('shows three skeleton rows after 150 ms and announces Searching…', async () => {
await mountRelation('/content/events/42', { search: () => new Promise(() => undefined) })
await typeSearch('venue', 'zzz')
await settleSearch(vi)
expect(skeletons()).toHaveLength(0)
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
await flushPromises()
expect(skeletons()).toHaveLength(3)
expect(
text(document.body.querySelector('[data-testid="relation-empty"] [role="status"]')),
).toBe('Searching…')
expect(optionLabels()).toEqual([])
})
})
describe('No match', () => {
it('names the query and clears the search', async () => {
await mountRelation('/content/events/42', {
search: (key, query) => searchBody(key, query),
})
await openList('venue', vi, 'zzz')
expect(emptyText()).toBe('No Venues match "zzz" Clear search')
expect(liveText('venue')).toBe('No results')
button('Clear search')!.click()
await flushPromises()
await settleSearch(vi)
expect(relationInput('venue').value).toBe('')
expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?page=1&size=10')
})
it('says the type has no entries at all', async () => {
await mountRelation('/content/events/42', { search: () => empty })
await openList('venue', vi)
expect(emptyText()).toBe('No Venues yet. Add one in Content, then come back.')
})
})
describe('Search failures', () => {
it.each([
['500', new ApiError(500)],
['a network error', new NetworkError()],
['404', new ApiError(404)],
])(
'shows the load error on %s and sends only the search again on Try again',
async (_name, error) => {
let fail = true
await mountRelation('/content/events/42', {
search: (key, query) => {
if (fail) {
throw error
}
return searchBody(key, query)
},
})
await openList('venue', vi, 'rue')
expect(emptyText()).toBe(
"Couldn't load Venues Check your connection and try again. Try again",
)
fail = false
const before = calls.length
button('Try again')!.click()
await flushPromises()
expect(calls.slice(before)).toEqual([
'/v1/content-type/venues/entry?q=rue&page=1&size=10',
])
expect(optionLabels()).toHaveLength(3)
},
)
it('shows the rate limit countdown on 429', async () => {
await mountRelation('/content/events/42', { search: failing(new ApiError(429, 3)) })
await openList('venue', vi, 'rue')
expect(emptyText()).toBe('Too many requests Try again in 0:03')
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(emptyText()).toContain('Wait a moment before trying again.')
expect(button('Try again')).toBeDefined()
})
it('adds no local copy on 401', async () => {
await mountRelation('/content/events/42', { search: failing(new ApiError(401)) })
await openList('venue', vi, 'rue')
expect(emptyText()).toBe('')
})
})
describe('Load more', () => {
it('appends the next page', async () => {
const names = Array.from({ length: 12 }, (_, index) => `Hall ${index + 1}`)
await mountRelation('/content/events/42', {
search: (_key, query) => {
const page = Number(query.get('page'))
const slice = names.slice((page - 1) * 10, page * 10)
return {
items: slice.map((name, index) => ({
id: 100 + (page - 1) * 10 + index,
data: { name, city: '', details: '' },
relations: {},
})),
total: 12,
page,
size: 10,
}
},
})
await openList('venue', vi)
expect(optionLabels()).toHaveLength(10)
button('Load more')!.click()
await flushPromises()
expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?page=2&size=10')
expect(optionLabels()).toHaveLength(12)
expect(button('Load more')).toBeUndefined()
})
})
describe('A missing target', () => {
it('shows a dashed chip and lets an optional field save with the same id', async () => {
await mountRelation('/content/events/42', {
venue: 3,
relations: { venue: { id: 3, missing: true } },
})
expect(chipLabels('venue')).toEqual(['Entry no longer exists'])
expect(fieldWrapper('venue').querySelector('[data-missing="true"]')).not.toBeNull()
expect(text(fieldWrapper('venue').querySelector('[data-testid="relation-help"]'))).toBe(
'You can still save. The entry keeps the link until you change it.',
)
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(entryWrites[0]!.body.data).toMatchObject({ venue: 3 })
})
it('refuses to save a required field with only a missing target', async () => {
await mountRelation('/content/events/42', {
venue: 3,
venueField: { required: true },
relations: { venue: { id: 3, missing: true } },
})
expect(text(fieldWrapper('venue').querySelector('[data-testid="relation-help"]'))).toBe(
'Venue is required. Choose another to save.',
)
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(entryWrites).toHaveLength(0)
expect(relationError('venue')).toBe('Choose a Venue.')
expect(document.activeElement).toBe(relationInput('venue'))
})
it('replaces the missing chip when another target is picked', async () => {
await mountRelation('/content/events/42', {
venue: 3,
relations: { venue: { id: 3, missing: true } },
})
await openList('venue', vi, 'rue')
await pickOption('Rue Verte yard')
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
})
})
@@ -388,7 +388,7 @@ describe('Saved fields are locked', () => {
expect(text(inspector())).toContain("Used in the API, so it can't change.") expect(text(inspector())).toContain("Used in the API, so it can't change.")
expect(inspector()!.querySelector('button[aria-label="Edit key"]')).toBeNull() expect(inspector()!.querySelector('button[aria-label="Edit key"]')).toBeNull()
expect(text(inspector())).toContain("Can't change after saving") expect(text(inspector())).toContain("Can't change after saving")
expect(all('[role="radio"]')).toHaveLength(7) expect(all('[role="radio"]')).toHaveLength(8)
expect(tile('Date').getAttribute('data-state')).toBe('checked') expect(tile('Date').getAttribute('data-state')).toBe('checked')
const others = all<HTMLButtonElement>('[role="radio"]').filter( const others = all<HTMLButtonElement>('[role="radio"]').filter(
(radio) => radio !== tile('Date'), (radio) => radio !== tile('Date'),
@@ -76,6 +76,7 @@ const FIELD_KEYS = [
'min', 'min',
'max', 'max',
'options', 'options',
'relation',
] ]
async function editRecipes() { async function editRecipes() {
@@ -57,6 +57,7 @@ const TILES = [
['Date', 'calendar', 'A calendar day, without a time'], ['Date', 'calendar', 'A calendar day, without a time'],
['Choice', 'list', 'One option from a list you define'], ['Choice', 'list', 'One option from a list you define'],
['Media', 'image', 'One image from your media library'], ['Media', 'image', 'One image from your media library'],
['Relation', 'link', "Links to another type's entries"],
] ]
const HINT = const HINT =
@@ -70,14 +71,14 @@ async function addMediaField() {
await pickTile('Media') await pickTile('Media')
} }
describe('Seven type tiles', () => { describe('Eight type tiles', () => {
it('lists the seven tiles in order, each with its name and description', async () => { it('lists the eight tiles in order, each with its name and description', async () => {
await mountAt('/schema/events') await mountAt('/schema/events')
await click(button('Add field')) await click(button('Add field'))
const labels = all('[role="radio"]').map((radio) => radio.closest('label')!) const labels = all('[role="radio"]').map((radio) => radio.closest('label')!)
expect(labels).toHaveLength(7) expect(labels).toHaveLength(8)
labels.forEach((label, index) => { labels.forEach((label, index) => {
const [name, , description] = TILES[index]! const [name, , description] = TILES[index]!
expect(text(label)).toBe(`${name} ${description}`) expect(text(label)).toBe(`${name} ${description}`)
@@ -112,7 +113,7 @@ describe('Seven type tiles', () => {
}) })
describe('Adding a media field', () => { describe('Adding a media field', () => {
it('shows the New badge, an editable Auto key with its help and all seven tiles enabled', async () => { it('shows the New badge, an editable Auto key with its help and all eight tiles enabled', async () => {
await mountAt('/schema/events') await mountAt('/schema/events')
await addMediaField() await addMediaField()
@@ -123,7 +124,7 @@ describe('Adding a media field', () => {
expect(text(inspector())).toContain( expect(text(inspector())).toContain(
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.', 'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
) )
expect(all('[role="radio"]')).toHaveLength(7) expect(all('[role="radio"]')).toHaveLength(8)
expect(all<HTMLButtonElement>('[role="radio"]').every((radio) => !radio.disabled)).toBe( expect(all<HTMLButtonElement>('[role="radio"]').every((radio) => !radio.disabled)).toBe(
true, true,
) )
@@ -198,17 +199,17 @@ describe('The inspector of a saved media field', () => {
expect(inspector()!.querySelector('[data-testid="slug-lock"]')).not.toBeNull() expect(inspector()!.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
}) })
it('selects Media, dims the six other tiles and makes them unselectable', async () => { it('selects Media, dims the seven other tiles and makes them unselectable', async () => {
await mountAt('/schema/events', savedThumbnail()) await mountAt('/schema/events', savedThumbnail())
await selectRow('Thumbnail') await selectRow('Thumbnail')
expect(all('[role="radio"]')).toHaveLength(7) expect(all('[role="radio"]')).toHaveLength(8)
expect(tile('Media').getAttribute('data-state')).toBe('checked') expect(tile('Media').getAttribute('data-state')).toBe('checked')
const others = all<HTMLButtonElement>('[role="radio"]').filter( const others = all<HTMLButtonElement>('[role="radio"]').filter(
(radio) => radio !== tile('Media'), (radio) => radio !== tile('Media'),
) )
expect(others).toHaveLength(6) expect(others).toHaveLength(7)
expect(others.every((radio) => radio.disabled || radio.hasAttribute('data-disabled'))).toBe( expect(others.every((radio) => radio.disabled || radio.hasAttribute('data-disabled'))).toBe(
true, true,
) )
@@ -330,6 +331,7 @@ describe('Saving a media field', () => {
min: null, min: null,
max: null, max: null,
options: [], options: [],
relation: null,
}) })
}) })
@@ -354,6 +356,7 @@ describe('Saving a media field', () => {
min: null, min: null,
max: null, max: null,
options: [], options: [],
relation: null,
}) })
}) })
@@ -0,0 +1,537 @@
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,
deletes,
entryPage,
failWithBody,
mountAt,
posts,
puts,
text,
unmountAll,
} from './schemaTestKit'
import {
addField,
all,
badgesOf,
byLabel,
chip,
click,
dialog,
el,
fieldKeyInput,
inspector,
inspectorTitle,
labelInput,
nameInput,
pickTile,
rowOf,
saveButton,
selectRow,
tile,
toggle,
typeInto,
} from './builderKit'
import { drawsIcon } from './mediaFieldKit'
import { allTypes, eventsWithRelations, speakersType, venuesType } from './relationKit'
import { customTypes } from './schemaTestKit'
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
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
afterEach(async () => {
vi.restoreAllMocks()
await unmountAll()
})
const SENTENCES = {
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.',
}
const types = () => [customTypes[0]!, venuesType, speakersType, customTypes[1]!]
function mountBuilder(path = '/schema/events', options: object = {}) {
return mountAt(path, { content: types, ...options })
}
const picker = () => el('[data-testid="relation-picker"]')!
const cards = () => all('[data-testid="relation-picker"] [role="radio"]')
const selectTrigger = () => picker().querySelector<HTMLElement>('button[role="combobox"]')!
const ownerSentence = () => text(el('[data-testid="relation-owner-sentence"]'))
const inverseSentence = () => text(el('[data-testid="relation-inverse-sentence"]'))
function openTrigger(trigger: Element) {
trigger.dispatchEvent(
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
pointerType: 'mouse',
}),
)
}
async function chooseOption(trigger: Element, label: string) {
openTrigger(trigger)
await flushPromises()
const option = all('[role="option"]').find((candidate) => text(candidate).startsWith(label))!
option.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
option.click()
await flushPromises()
}
async function addRelation(label = 'Venue') {
await addField(label)
await pickTile('Relation')
}
async function relateTo(name: string) {
await chooseOption(selectTrigger(), name)
}
describe('The type gallery', () => {
it('gains Relation as the last tile with the link icon, selectable like the others', async () => {
await mountBuilder()
await click(button('Add field'))
const labels = all('[role="radio"]').map((radio) => radio.closest('label')!)
expect(labels).toHaveLength(8)
const last = labels[7]!
expect(text(last)).toBe("Relation Links to another type's entries")
expect(drawsIcon(last, 'link')).toBe(true)
expect(tile('Relation').disabled).toBe(false)
})
it('dims the other tiles on a saved relation', async () => {
await mountBuilder('/schema/events', {
type: () => eventsWithRelations(),
content: () => allTypes(),
})
await selectRow('Venue')
expect(tile('Relation').getAttribute('data-state')).toBe('checked')
const others = all<HTMLButtonElement>('[role="radio"]').filter((radio) =>
radio.closest('label')!.innerHTML.includes('opacity-40'),
)
expect(others).toHaveLength(7)
expect(text(inspector())).toContain("Can't change after saving")
})
})
describe('Adding a relation field', () => {
it('shows New, an editable Auto key, the unsaved chip and the relation controls in order', async () => {
await mountBuilder()
await addRelation()
expect(badgesOf('Venue')).toContain('New')
expect(fieldKeyInput()!.value).toBe('venue')
expect(inspector()!.querySelector('button[aria-label="Edit key"]')).not.toBeNull()
expect(chip()).toBe('1 problem to fix')
expect(inspectorTitle()).toBe('Field: Venue')
const copy = text(inspector())
expect(copy.indexOf('Type')).toBeLessThan(copy.indexOf('Related content type'))
expect(copy.indexOf('Related content type')).toBeLessThan(
copy.indexOf('How are they connected?'),
)
expect(copy).toContain('Choose the content type this field points to.')
})
it('cannot send a relation without a related type', async () => {
await mountBuilder('/schema/events', {
entries: () => entryPage(0),
})
await addRelation()
await click(saveButton())
expect(puts).toHaveLength(0)
expect(text(inspector())).toContain('Choose a related content type.')
})
})
describe('The related content type select', () => {
it('lists every custom type by name with its key, Events included', async () => {
await mountBuilder()
await addRelation()
openTrigger(selectTrigger())
await flushPromises()
expect(all('[role="option"]').map(text)).toEqual([
'Events events',
'Venues venues',
'Speakers speakers',
'Recipes recipes',
])
})
it('sets the sentences of the preview from the chosen type', async () => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
expect(ownerSentence()).toBe('Each Event has one Venue')
expect(inverseSentence()).toContain('Each Venue has many Events')
})
})
describe('The three shape cards', () => {
it('shows the titles and sentences, with One to many selected by default', async () => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
expect(cards().map((card) => text(card.closest('label')))).toEqual([
`One to one ${SENTENCES.one_to_one}`,
`One to many ${SENTENCES.one_to_many}`,
`Many to many ${SENTENCES.many_to_many}`,
])
expect(cards().map((card) => card.getAttribute('data-state'))).toEqual([
'unchecked',
'checked',
'unchecked',
])
expect(cards()[1]!.closest('label')!.className).toContain(
'has-data-[state=checked]:bg-(--rc-sand)',
)
})
it('is a radio group, one card at a time', async () => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
await click(cards()[2])
expect(cards().map((card) => card.getAttribute('data-state'))).toEqual([
'unchecked',
'unchecked',
'checked',
])
})
it('stacks on one column on mobile', async () => {
await mountBuilder('/schema/events', { width: MOBILE_WIDTH })
await addRelation()
const fieldset = cards()[0]!.closest('[class*="grid-cols"]')!
expect(fieldset.className).toContain('grid-cols-1')
})
})
describe('The preview of both sides', () => {
it.each([
['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'],
])('follows the card %s', async (card, owner, inverse) => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
await click(cards().find((radio) => text(radio.closest('label')).startsWith(card)))
expect(ownerSentence()).toBe(owner)
expect(inverseSentence()).toContain(inverse)
})
it('shows the muted note and the board example cards for One to many', async () => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
expect(inverseSentence()).toContain('· shown on Venue as the field "Events"')
expect(el('[data-testid="relation-preview"]')!.className).toContain('bg-elevated')
expect(text(el('[data-testid="relation-example-owner"]'))).toBe(
'Event Venue Rue Verte hall',
)
expect(text(el('[data-testid="relation-example-related"]'))).toBe(
'Venue Events Open day Lime plaster workshop +1',
)
})
it('changes the kind through the selects of the sentences', async () => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
const triggers = all('[data-testid="relation-preview"] button[role="combobox"]')
await chooseOption(triggers[0]!, 'many')
expect(cards().map((card) => card.getAttribute('data-state'))).toEqual([
'unchecked',
'unchecked',
'checked',
])
await chooseOption(triggers[1]!, 'one')
expect(cards().map((card) => card.getAttribute('data-state'))).toEqual([
'checked',
'unchecked',
'unchecked',
])
})
it('sends nothing about the inverse side', async () => {
await mountBuilder('/schema/events', { entries: () => entryPage(0) })
await addRelation()
await relateTo('Venues')
await click(saveButton())
const fields = (puts[0]!.body as { fields: { relation: unknown }[] }).fields
expect(fields[7]!.relation).toEqual({ content_type: 'venues', kind: 'one_to_many' })
expect(JSON.stringify(puts[0]!.body)).not.toContain('inverse')
})
})
describe('Plural and names', () => {
it('draws singular and plural forms from the type names and the field label', async () => {
await mountBuilder()
await addRelation('Place')
await relateTo('Venues')
await click(cards()[2])
expect(ownerSentence()).toBe('Each Event has many Venues')
expect(inverseSentence()).toContain('Each Place has many Events')
})
})
describe('The PUT and POST body', () => {
it('sends the relation as content_type and kind and every other field with relation null', async () => {
await mountBuilder('/schema/events', { entries: () => entryPage(0) })
await addRelation()
await relateTo('Venues')
await click(saveButton())
expect(puts).toHaveLength(1)
const fields = (puts[0]!.body as { fields: Record<string, unknown>[] }).fields
expect(fields[7]).toEqual({
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' },
})
expect(fields.slice(0, 7).every((field) => field.relation === null)).toBe(true)
})
it('sends the chosen kind in the POST body of a new type', async () => {
await mountBuilder('/schema/new')
await typeInto(nameInput(), 'Gallery')
await selectRow('Title')
await addRelation('Speakers')
await relateTo('Speakers')
await click(cards()[2])
await click(saveButton())
const fields = (posts[0]!.body as { fields: Record<string, unknown>[] }).fields
expect(fields[1]!.relation).toEqual({ content_type: 'speakers', kind: 'many_to_many' })
})
it('makes a no-change round trip of a loaded relation', async () => {
await mountBuilder('/schema/events', {
type: () => eventsWithRelations(),
content: () => allTypes(),
entries: () => entryPage(0),
})
await typeInto(nameInput(), 'Events and talks')
await click(saveButton())
const fields = (puts[0]!.body as { fields: Record<string, unknown>[] }).fields
expect(fields[7]).toMatchObject({
key: 'venue',
relation: { content_type: 'venues', kind: 'one_to_many' },
})
expect(fields[8]).toMatchObject({
key: 'speakers',
relation: { content_type: 'speakers', kind: 'many_to_many' },
})
})
})
describe('The inspector of a saved relation', () => {
async function openSaved() {
await mountBuilder('/schema/events', {
type: () => eventsWithRelations(),
content: () => allTypes(),
})
await selectRow('Venue')
}
it('locks the key, the tiles, the related type and the cards but not Label or Required', async () => {
await openSaved()
expect(fieldKeyInput()!.readOnly).toBe(true)
expect(labelInput()!.disabled).toBe(false)
expect(selectTrigger().hasAttribute('disabled')).toBe(true)
expect(
cards().every(
(card) => card.hasAttribute('disabled') || card.hasAttribute('data-disabled'),
),
).toBe(true)
expect(cards()[1]!.getAttribute('data-state')).toBe('checked')
expect(text(el('[data-testid="relation-locked-note"]'))).toBe(
"The related type and the connection can't change after saving.",
)
expect(text(selectTrigger())).toContain('Venues')
await toggle('Required')
expect(chip()).toBe('Unsaved changes')
})
})
describe('Review before saving', () => {
async function reviewRelation(required: boolean, total = 12) {
await mountBuilder('/schema/events', { entries: () => entryPage(total) })
await addRelation()
await relateTo('Venues')
if (required) {
await toggle('Required')
}
await click(saveButton())
}
it('refuses a required relation on a type with entries without sending the PUT', async () => {
await reviewRelation(true)
expect(puts).toHaveLength(0)
expect(text(dialog())).toContain('Review changes to Events')
expect(text(el('[data-testid="change-title"]'))).toBe('Add Venue')
expect(text(el('[data-testid="change-flag"]'))).toBe("Can't be applied")
expect(text(el('[data-testid="change-text"]'))).toBe(
"Relation, required. A relation can't have a default, so the 12 existing entries would have no Venue. Make Venue optional to add it.",
)
expect((button('Back to editing') as HTMLButtonElement).disabled).toBe(false)
button('Apply changes')!.click()
await flushPromises()
expect(puts).toHaveLength(0)
})
it('shows the clean row for an optional relation and applies it', async () => {
await reviewRelation(false)
expect(text(el('[data-testid="change-text"]'))).toBe(
'Relation, optional. 12 entries are unaffected.',
)
expect(el('[data-testid="change-flag"]')).toBeNull()
await click(button('Apply changes'))
expect(puts).toHaveLength(1)
})
it('sends a required relation at once when the type has no entries', async () => {
await reviewRelation(true, 0)
expect(dialog()).toBeNull()
expect(puts).toHaveLength(1)
})
it('flags making a saved optional relation required', async () => {
await mountBuilder('/schema/events', {
type: () => eventsWithRelations(),
content: () => allTypes(),
entries: () => entryPage(12),
})
await selectRow('Venue')
await toggle('Required')
await click(saveButton())
expect(text(el('[data-testid="change-flag"]'))).toBe('Entries may not fit')
expect((button('Apply changes') as HTMLButtonElement).hasAttribute('aria-disabled')).toBe(
false,
)
})
})
describe('The server answers on the builder', () => {
it('runs the existing refusal flow on 409 schema_incompatible', async () => {
await mountBuilder('/schema/events', {
entries: () => entryPage(0),
update: failWithBodyUpdate(409, 'schema_incompatible'),
})
await addRelation()
await relateTo('Venues')
await click(saveButton())
expect(text(dialog())).toContain('Nothing was changed')
})
it('shows the invalid-field failure on 422 and keeps the edits', async () => {
await mountBuilder('/schema/events', {
entries: () => entryPage(0),
update: failWithBodyUpdate(422, 'invalid_field'),
})
await addRelation()
await relateTo('Venues')
await click(saveButton())
expect(text(el('[role="alert"]'))).toContain('Check the fields marked in the list.')
expect(rowOf('Venue')).toBeDefined()
expect(selectTrigger()).not.toBeNull()
})
})
function failWithBodyUpdate(status: number, body: string) {
const respond = failWithBody(status, body)
return () => respond()
}
describe('Deleting a type that another type points at', () => {
it('shows the referenced refusal and keeps the type', async () => {
await mountAt('/schema/venues', {
content: () => allTypes(),
type: (key: string) => allTypes().find((type) => type.key === key),
entries: () => entryPage(0),
remove: failWithBody(409, 'content_type_referenced'),
})
await click(byLabel('More actions'))
await click(all('[role="menuitem"]').find((item) => text(item) === 'Delete content type'))
await click(
all('button', dialog()).find((candidate) =>
text(candidate).startsWith('Delete content type'),
),
)
expect(deletes).toEqual(['/v1/content-type/venues'])
expect(text(dialog())).toContain("You can't delete Venues yet")
expect(text(dialog())).toContain(
'Events has a field that points to Venues. Remove that field first.',
)
expect(dialog()).not.toBeNull()
})
})
describe('The builder is for owners and admins', () => {
it('keeps the forbidden state and renders no relation control', async () => {
await mountAt('/schema/events', { role: 'user', content: types })
expect(text(el('#dashboard-content'))).toContain("You don't have access to Schema")
expect(el('[data-testid="relation-picker"]')).toBeNull()
})
})
@@ -70,6 +70,7 @@ const EVENTS_BODY = {
min: null, min: null,
max: 80, max: 80,
options: [], options: [],
relation: null,
}, },
{ {
key: 'starts_on', key: 'starts_on',
@@ -81,6 +82,7 @@ const EVENTS_BODY = {
min: '2026-01-01', min: '2026-01-01',
max: '2027-12-31', max: '2027-12-31',
options: [], options: [],
relation: null,
}, },
{ {
key: 'category', key: 'category',
@@ -92,6 +94,7 @@ const EVENTS_BODY = {
min: null, min: null,
max: null, max: null,
options: ['Talk', 'Workshop', 'Meetup'], options: ['Talk', 'Workshop', 'Meetup'],
relation: null,
}, },
], ],
} }
@@ -160,7 +160,7 @@ describe('New type, desktop (design state 1)', () => {
expect(copy).toContain( expect(copy).toContain(
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.', 'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
) )
expect(all('[role="radio"]')).toHaveLength(7) expect(all('[role="radio"]')).toHaveLength(8)
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain( expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
'Text', 'Text',
) )
+254
View File
@@ -0,0 +1,254 @@
import { flushPromises } from '@vue/test-utils'
import type { RelationKind, RelationSummary } from '@/lib/relationField'
import {
answerWrite,
entry42,
fieldWrapper,
lookupNames,
mountEditor,
text,
} from './entryEditorKit'
import { apiField, customTypes, eventsFields, type SchemaMount } from './schemaTestKit'
export const SEARCH_DEBOUNCE_MS = 200
export const SEARCH_SIZE = 10
interface Row {
id: number
data: Record<string, unknown>
}
export const VENUES: Row[] = [
{ id: 3, data: { name: 'Rue Verte hall', city: 'Lyon', details: '120 seats' } },
{ id: 4, data: { name: 'Rue Verte yard', city: 'Lyon', details: 'outdoor' } },
{ id: 5, data: { name: 'Rue du Port studio', city: 'Nantes', details: '24 seats' } },
{ id: 6, data: { name: 'Quai Nord', city: 'Nantes', details: '' } },
]
export const SPEAKERS: Row[] = [
{ id: 11, data: { name: 'Ines Duarte' } },
{ id: 12, data: { name: 'Jonas Pereira' } },
{ id: 13, data: { name: 'Ana Costa' } },
{ id: 14, data: { name: 'Luc Moreau' } },
]
const ROWS: Record<string, Row[]> = { venues: VENUES, speakers: SPEAKERS }
const FIRST_FIELD: Record<string, string> = { venues: 'name', speakers: 'name' }
export const venuesType = {
...customTypes[1]!,
id: 4,
key: 'venues',
name: 'Venues',
fields: [
apiField('Name', 'name', 'text', { required: true, options: [] }),
apiField('City', 'city', 'text', { options: [] }),
apiField('Details', 'details', 'text', { options: [] }),
],
}
export const speakersType = {
...customTypes[1]!,
id: 5,
key: 'speakers',
name: 'Speakers',
fields: [apiField('Name', 'name', 'text', { required: true, options: [] })],
}
export function relationField(
label: string,
key: string,
target: string,
kind: RelationKind,
rest: object = {},
) {
return apiField(label, key, 'relation', {
options: [],
relation: { content_type: target, kind },
...rest,
})
}
export function eventsWithRelations(
venue: object = {},
speakers: object = {},
venueKind: RelationKind = 'one_to_many',
) {
return {
...customTypes[0]!,
fields: [
...eventsFields,
relationField('Venue', 'venue', 'venues', venueKind, venue),
relationField('Speakers', 'speakers', 'speakers', 'many_to_many', speakers),
],
}
}
export function allTypes(venue: object = {}, speakers: object = {}) {
return [eventsWithRelations(venue, speakers), venuesType, speakersType]
}
export function summaryFor(typeKey: string, id: number): RelationSummary {
const row = ROWS[typeKey]!.find((candidate) => candidate.id === id)!
const key = FIRST_FIELD[typeKey]!
return {
id,
content_type: typeKey,
first_field: { key, type: 'text', value: row.data[key] },
}
}
export const MISSING_VENUE: RelationSummary = { id: 3, missing: true }
export function entryWithRelations(
venue: number | null,
speakers: number[],
relations?: Record<string, RelationSummary | RelationSummary[]>,
) {
const shown = relations ?? {
...(venue === null ? {} : { venue: summaryFor('venues', venue) }),
...(speakers.length === 0
? {}
: { speakers: speakers.map((id) => summaryFor('speakers', id)) }),
}
return {
...entry42,
data: { ...entry42.data, venue, speakers },
relations: shown,
}
}
export function searchBody(typeKey: string, query: URLSearchParams) {
const needle = (query.get('q') ?? '').toLowerCase()
const page = Number(query.get('page') ?? '1')
const size = Number(query.get('size') ?? String(SEARCH_SIZE))
const matches = ROWS[typeKey]!.filter((row) =>
String(row.data[FIRST_FIELD[typeKey]!]).toLowerCase().includes(needle),
)
const items = matches.slice((page - 1) * size, page * size).map((row) => ({
...entry42,
id: row.id,
content_type: typeKey,
data: row.data,
relations: {},
}))
return { items, total: matches.length, page, size }
}
export function answerRelationWrite(write: {
method: string
url: string
body: Record<string, unknown>
}) {
const answered = answerWrite(write as never) as { data: Record<string, unknown> }
const data = answered.data
const venue = data.venue
const speakers = (data.speakers as number[] | undefined) ?? []
return {
...answered,
relations: {
...(typeof venue === 'number' ? { venue: summaryFor('venues', venue) } : {}),
...(speakers.length > 0
? { speakers: speakers.map((id) => summaryFor('speakers', id)) }
: {}),
},
}
}
export interface RelationMount extends SchemaMount {
venue?: number | null
speakers?: number[]
venueField?: object
speakersField?: object
relations?: Record<string, RelationSummary | RelationSummary[]>
search?: (typeKey: string, query: URLSearchParams) => unknown
}
export function mountRelation(path: string, options: RelationMount = {}) {
const {
venue = null,
speakers = [],
venueField,
speakersField,
relations,
search,
...rest
} = options
const types = allTypes(venueField, speakersField)
return mountEditor(path, {
type: (key) => types.find((type) => type.key === key),
content: () => types,
entry: () => entryWithRelations(venue, speakers, relations),
writeEntry: answerRelationWrite,
lookup: lookupNames,
list: (key, query) => (search ?? searchBody)(key, query),
...rest,
})
}
export function relationInput(key: string) {
return fieldWrapper(key).querySelector<HTMLInputElement>('input[role="combobox"]')!
}
export function chipLabels(key: string) {
return [...fieldWrapper(key).querySelectorAll('[data-slot="tagsItemText"]')].map((item) =>
text(item),
)
}
export function optionLabels() {
return [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].map((option) =>
text(option),
)
}
export async function typeSearch(key: string, value: string) {
const input = relationInput(key)
input.focus()
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
export async function settleSearch(vi: {
advanceTimersByTimeAsync: (ms: number) => Promise<unknown>
}) {
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS + 10)
await flushPromises()
}
export function liveText(key: string) {
return text(fieldWrapper(key).querySelector('[data-testid="relation-live"]'))
}
export async function pickOption(label: string) {
const option = [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].find(
(candidate) => text(candidate).startsWith(label),
)!
option.click()
await flushPromises()
}
export async function openList(key: string, vi: Parameters<typeof settleSearch>[0], value = '') {
await typeSearch(key, value)
await settleSearch(vi)
}
export function removeButtons(key: string) {
return [
...fieldWrapper(key).querySelectorAll<HTMLButtonElement>('[data-slot="tagsItemDelete"]'),
]
}
export async function press(key: string, name: string) {
relationInput(key).dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true }))
await flushPromises()
}
export function relationError(key: string) {
return text(fieldWrapper(key).querySelector('[role="alert"]'))
}
export function emptyText() {
return text(document.body.querySelector('[data-testid="relation-empty"]'))
}
+18
View File
@@ -74,6 +74,12 @@ const extraSources: Record<string, Source[]> = {
], ],
alias: {}, alias: {},
}, },
{
board: schemaBoard,
group: '7 Relations',
states: ['Relation in the builder'],
alias: {},
},
{ {
board: schemaBoard, board: schemaBoard,
group: '3 Review and delete', group: '3 Review and delete',
@@ -97,6 +103,12 @@ const extraSources: Record<string, Source[]> = {
states: ['Thumbnail column', 'Columns menu with Thumbnail', 'Missing image: tooltip'], states: ['Thumbnail column', 'Columns menu with Thumbnail', 'Missing image: tooltip'],
alias: {}, alias: {},
}, },
{
board: schemaBoard,
group: '7 Relations',
states: ['Relation in the list'],
alias: {},
},
], ],
entry: [ entry: [
{ {
@@ -116,6 +128,12 @@ const extraSources: Record<string, Source[]> = {
], ],
alias: {}, alias: {},
}, },
{
board: schemaBoard,
group: '7 Relations',
states: ['Relation in the entry form'],
alias: {},
},
{ {
board: schemaBoard, board: schemaBoard,
group: '10 Media picker for fields', group: '10 Media picker for fields',
+211 -2
View File
@@ -8104,13 +8104,222 @@ function withMediaExceptions(screen: string, states: Record<string, VisualState>
) )
} }
function relationFieldOf(label: string, key: string, target: string, kind: string) {
return schemaField(label, key, 'relation', {
options: [],
relation: { content_type: target, kind },
})
}
function relationTypeOf(id: number, key: string, name: string, fields: object[]) {
return {
id,
key,
name,
icon: 'files',
fields,
created_at: '2026-09-01T09:30:00Z',
updated_at: '2026-10-01T09:30:00Z',
}
}
const relationVenueRows = [
{ id: 3, name: 'Rue Verte hall', city: 'Lyon', details: '120 seats' },
{ id: 4, name: 'Rue Verte yard', city: 'Lyon', details: 'outdoor' },
{ id: 5, name: 'Rue du Port studio', city: 'Nantes', details: '24 seats' },
]
const relationSpeakerRows = [
{ id: 11, name: 'Inès Duarte' },
{ id: 12, name: 'Jonas Pereira' },
{ id: 13, name: 'Ana Costa' },
{ id: 14, name: 'Luc Moreau' },
]
const relationVenuesType = relationTypeOf(4, 'venues', 'Venues', [
schemaField('Name', 'name', 'text', { required: true, options: [] }),
schemaField('City', 'city', 'text', { options: [] }),
schemaField('Details', 'details', 'text', { options: [] }),
])
const relationSpeakersType = relationTypeOf(5, 'speakers', 'Speakers', [
schemaField('Name', 'name', 'text', { required: true, options: [] }),
])
function relationTypes(events: object) {
return [events, ...schemaCustomTypes.slice(1), relationSpeakersType, relationVenuesType]
}
function venueSummary(id: number) {
const row = relationVenueRows.find((candidate) => candidate.id === id)!
return {
id,
content_type: 'venues',
first_field: { key: 'name', type: 'text', value: row.name },
}
}
function speakerSummary(id: number) {
const row = relationSpeakerRows.find((candidate) => candidate.id === id)!
return {
id,
content_type: 'speakers',
first_field: { key: 'name', type: 'text', value: row.name },
}
}
function relationSearch(url: URL) {
const needle = (url.searchParams.get('q') ?? '').toLowerCase()
const matches = relationVenueRows.filter((row) => row.name.toLowerCase().includes(needle))
return {
items: matches.map((row) => ({
id: row.id,
content_type: 'venues',
data: { name: row.name, city: row.city, details: row.details },
created_by: 3,
updated_by: 3,
created_at: '2026-10-02T09:14:00Z',
updated_at: '2026-10-12T11:02:00Z',
relations: {},
})),
total: matches.length,
page: 1,
size: 10,
}
}
const relationBuilderStates: Record<string, VisualState> = {
'Relation in the builder': {
exception: {
maxPercent: { desktop: 27, mobile: 34.5 },
reason: 'the board draws only the inspector under its own title, without the type bar, field list and API preview, plus a stray board miniature, while the app is the full builder and a bottom sheet on mobile',
approvedBy: 'Florian Sylvain',
},
path: '/schema/events',
api: schemaApi({
'GET /v1/content-type': { status: 200, body: relationTypes(schemaCustomTypes[0]!) },
'GET /v1/content-type/events/entry': { status: 200, body: schemaEntryPage(0) },
}),
steps: async (page) => {
await schemaAddField(page, 'Venue')
await page.locator('label:has([role="radio"][value="relation"])').click()
await page
.locator('[data-testid="relation-picker"] button[role="combobox"]')
.first()
.click()
await page.getByRole('option', { name: /Venues/ }).click()
await schemaSettle(page)
},
},
}
const relationEntryType = relationTypeOf(1, 'events', 'Events', [
schemaField('Title', 'title', 'text', { required: true, unique: true, max: 80, options: [] }),
relationFieldOf('Venue', 'venue', 'venues', 'one_to_many'),
relationFieldOf('Speakers', 'speakers', 'speakers', 'many_to_many'),
])
const relationEntryStates: Record<string, VisualState> = {
'Relation in the entry form': {
exception: {
maxPercent: { desktop: 10, mobile: 17 },
reason: 'on mobile the board reserves a spacer under the open list, hides the meta block and action bar and draws a stray miniature, while the app overlays the list on the next field and shows the meta block and sticky actions',
approvedBy: 'Florian Sylvain',
},
path: '/content/events/42',
api: entryApi({
'GET /v1/content-type': { status: 200, body: relationTypes(relationEntryType) },
'GET /v1/content-type/events': { status: 200, body: relationEntryType },
'GET /v1/content-type/events/entry/42': {
status: 200,
body: entryRecord(
{ venue: null, speakers: [11, 12] },
{ relations: { speakers: [speakerSummary(11), speakerSummary(12)] } },
),
},
'GET /v1/content-type/venues/entry': { status: 200, bodyFor: relationSearch },
}),
steps: async (page) => {
const input = entryField(page, 'venue').getByRole('combobox')
await input.click()
await input.fill('rue')
await page.getByRole('option').first().waitFor()
await schemaSettle(page)
},
},
}
const relationListType = relationTypeOf(1, 'events', 'Events', [
schemaField('Title', 'title', 'text', { required: true, unique: true, max: 80, options: [] }),
schemaField('Starts on', 'starts_on', 'date', { required: true, options: [] }),
relationFieldOf('Venue', 'venue', 'venues', 'one_to_many'),
relationFieldOf('Speakers', 'speakers', 'speakers', 'many_to_many'),
schemaField('Ends on', 'ends_on', 'date', { options: [] }),
schemaField('Online', 'online', 'boolean', { default: false, options: [] }),
schemaField('Capacity', 'capacity', 'number', { min: 1, max: 5000, options: [] }),
schemaField('Category', 'category', 'select', { options: ['Talk', 'Workshop', 'Meetup'] }),
schemaField('Description', 'description', 'richtext', { options: [] }),
])
const relationListVenues: Record<number, number | null> = { 42: 3, 41: null, 40: 4 }
const relationListSpeakers: Record<number, number[]> = { 42: [11, 12, 13, 14], 41: [12], 40: [] }
const relationListRows = contentRows.slice(0, 3).map((row) => {
const id = Number(row.id)
const venue = relationListVenues[id] ?? null
const speakers = relationListSpeakers[id] ?? []
return {
...row,
data: { ...row.data, venue, speakers },
relations: {
...(venue === null ? {} : { venue: venueSummary(venue) }),
...(speakers.length === 0 ? {} : { speakers: speakers.map(speakerSummary) }),
},
}
})
const relationListColumns = ['title', 'starts_on', 'venue', 'speakers']
const relationContentStates: Record<string, VisualState> = {
'Relation in the list': {
exception: {
maxPercent: { desktop: 10, mobile: 17.5 },
reason: 'on mobile the app card list has the sort and columns toolbar, the meta line and the New entry button that the board does not draw',
approvedBy: 'Florian Sylvain',
},
path: '/content/events',
api: contentApi({
'GET /v1/content-type': { status: 200, body: relationTypes(relationListType) },
'GET /v1/content-type/events': { status: 200, body: relationListType },
'GET /v1/content-type/events/entry': {
status: 200,
body: { items: relationListRows, total: 3, page: 1, size: 25 },
},
}),
steps: async (page) => {
await page.evaluate(
([key, value]) => localStorage.setItem(key!, value!),
[thumbnailColumnsKey, JSON.stringify(relationListColumns)],
)
await page.reload()
await page.locator('tbody tr, [data-testid="entry-card"]').first().waitFor()
await schemaSettle(page)
},
},
}
export const visualStates: Record<string, Record<string, VisualState>> = { export const visualStates: Record<string, Record<string, VisualState>> = {
sites: sitesStates, sites: sitesStates,
schema: { ...schemaStates, ...withMediaExceptions('schema', mediaSchemaStates) }, schema: {
content: { ...contentStates, ...mediaContentStates }, ...schemaStates,
...withMediaExceptions('schema', mediaSchemaStates),
...relationBuilderStates,
},
content: { ...contentStates, ...mediaContentStates, ...relationContentStates },
entry: { entry: {
...entryStates, ...entryStates,
...withMediaExceptions('entry', { ...mediaEntryStates, ...mediaPickerStates }), ...withMediaExceptions('entry', { ...mediaEntryStates, ...mediaPickerStates }),
...relationEntryStates,
}, },
authors: authorsStates, authors: authorsStates,
activity: activityStates, activity: activityStates,