diff --git a/api/handlers/contenttype/contentTypeHandler.go b/api/handlers/contenttype/contentTypeHandler.go index 07ef1ad..14d0f77 100644 --- a/api/handlers/contenttype/contentTypeHandler.go +++ b/api/handlers/contenttype/contentTypeHandler.go @@ -92,6 +92,8 @@ func writeContentTypeError(w http.ResponseWriter, err error) { http.Error(w, apperr.ContentTypeKeyTakenErrorMessage, http.StatusConflict) case errors.Is(err, domain.ErrHasEntries): 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): http.Error(w, apperr.SchemaIncompatibleErrorMessage, http.StatusConflict) case errors.Is(err, domain.ErrInvalidName): diff --git a/api/handlers/entry/entryHandler.go b/api/handlers/entry/entryHandler.go index 5ba53ef..4aeb010 100644 --- a/api/handlers/entry/entryHandler.go +++ b/api/handlers/entry/entryHandler.go @@ -43,6 +43,49 @@ type entryResponse struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` 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 { @@ -81,6 +124,7 @@ func toResponse(typed useCases.TypedEntry) entryResponse { CreatedAt: typed.CreatedAt, UpdatedAt: typed.UpdatedAt, 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) 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 { writeEntryError(w, err) return diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index c19ee90..8c3432d 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -92,6 +92,7 @@ const SlugSuggestionHeader = "X-Slug-Suggestion" const ContentTypeNotFoundErrorMessage = "content_type_not_found" const ContentTypeKeyTakenErrorMessage = "content_type_key_taken" const ContentTypeHasEntriesErrorMessage = "content_type_has_entries" +const ContentTypeReferencedErrorMessage = "content_type_referenced" const InvalidContentTypeKeyErrorMessage = "invalid_content_type_key" const InvalidIconErrorMessage = "invalid_icon" const BuiltinTypeNotFoundErrorMessage = "builtin_type_not_found" diff --git a/features/relation_field_spa.feature b/features/relation_field_spa.feature new file mode 100644 index 0000000..6d5bbc8 --- /dev/null +++ b/features/relation_field_spa.feature @@ -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: : required", "invalid_entry: : type", 409 "invalid_entry: : 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 is + + 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 relation "Venue" that is + When the value is + Then the rule is + + 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 reads + + 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 + Then the sentences read and + + 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 "" + Then + + 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= 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 + Then + + 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 with relations + When the user and activates "Save" + Then the control shows + And the data holds + + 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 "" + Then + + 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 + Then the cell shows + + 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 diff --git a/features/relation_field_spa.qa.md b/features/relation_field_spa.qa.md new file mode 100644 index 0000000..fe579bd --- /dev/null +++ b/features/relation_field_spa.qa.md @@ -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 -- `) +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. diff --git a/features/relation_field_type.feature b/features/relation_field_type.feature new file mode 100644 index 0000000..c149096 --- /dev/null +++ b/features/relation_field_type.feature @@ -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": , "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: : type" + And a required relation is never saved empty: null, absent or [] give 422 "invalid_entry: : 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: : 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 + 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 + Then the response status is and the body is + + 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 + Then the response status is 201, , 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 and "speakers" is many_to_many + When the client sends POST with the data + Then the response status is and the body is + 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 + Then the response status is and the body is + + 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 + Then the status is , the body is and the stored entry is + + 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= + Then total is + + 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 diff --git a/features/relation_field_type.qa.md b/features/relation_field_type.qa.md new file mode 100644 index 0000000..da5f01b --- /dev/null +++ b/features/relation_field_type.qa.md @@ -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. diff --git a/internal/domain/contenttype/contentTypeDomain.go b/internal/domain/contenttype/contentTypeDomain.go index cece14d..b1a020b 100644 --- a/internal/domain/contenttype/contentTypeDomain.go +++ b/internal/domain/contenttype/contentTypeDomain.go @@ -13,6 +13,7 @@ var ( ErrInvalidIcon = errors.New("invalid content type icon") ErrInvalidField = errors.New("invalid field") 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") ) @@ -26,6 +27,7 @@ const ( TypeDate FieldType = "date" TypeSelect FieldType = "select" TypeMedia FieldType = "media" + TypeRelation FieldType = "relation" ) const ( @@ -54,6 +56,7 @@ type Field struct { Min any `json:"min"` Max any `json:"max"` Options []string `json:"options"` + Relation *Relation `json:"relation"` } type ContentType struct { diff --git a/internal/domain/contenttype/fieldKinds.go b/internal/domain/contenttype/fieldKinds.go index 43859b4..bd61ac7 100644 --- a/internal/domain/contenttype/fieldKinds.go +++ b/internal/domain/contenttype/fieldKinds.go @@ -21,6 +21,7 @@ var kinds = map[FieldType]kind{ TypeDate: {accepts: isDate, measure: dateDays, bound: dateDays, unique: true}, TypeSelect: {accepts: isString, unique: true}, TypeMedia: {accepts: isMediaID}, + TypeRelation: {}, } func MediaID(value any) (uint32, bool) { diff --git a/internal/domain/contenttype/fieldSchema.go b/internal/domain/contenttype/fieldSchema.go index 8d9e441..b1009ab 100644 --- a/internal/domain/contenttype/fieldSchema.go +++ b/internal/domain/contenttype/fieldSchema.go @@ -63,7 +63,7 @@ func (f Field) validSchema() bool { if f.Unique && !fieldKind.unique { 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 { @@ -109,7 +109,7 @@ func (f Field) validDefault() bool { if f.Default == nil { return true } - if f.Type == TypeMedia { + if f.Type == TypeMedia || f.Type == TypeRelation { return false } return !f.Unique && f.Violation(f.Default) == "" diff --git a/internal/domain/contenttype/fieldValue.go b/internal/domain/contenttype/fieldValue.go index 2203df3..2fde8b5 100644 --- a/internal/domain/contenttype/fieldValue.go +++ b/internal/domain/contenttype/fieldValue.go @@ -3,6 +3,9 @@ package contenttype import "slices" func (f Field) Violation(value any) string { + if f.Type == TypeRelation { + return f.relationViolation(value) + } fieldKind, known := kinds[f.Type] if !known || !fieldKind.accepts(value) { return RuleType diff --git a/internal/domain/contenttype/relationField.go b/internal/domain/contenttype/relationField.go new file mode 100644 index 0000000..33200ec --- /dev/null +++ b/internal/domain/contenttype/relationField.go @@ -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 +} diff --git a/internal/domain/entry/entryData.go b/internal/domain/entry/entryData.go index b98c4be..2671b4d 100644 --- a/internal/domain/entry/entryData.go +++ b/internal/domain/entry/entryData.go @@ -2,8 +2,11 @@ package entry import ( "RenewCMS/internal/domain/contenttype" + "fmt" "reflect" "sort" + "strconv" + "strings" ) 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 { value := data[field.Key] - if value == nil { + if value == nil || contenttype.EmptyRelation(field, value) { if field.Required { 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 { var values []UniqueValue 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}) } } @@ -103,3 +106,38 @@ func MissingMedia(field contenttype.Field, unchanged bool) error { } 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) +} diff --git a/internal/infrastructure/useCases/CreateContentType.go b/internal/infrastructure/useCases/CreateContentType.go index c948c1c..5c3d975 100644 --- a/internal/infrastructure/useCases/CreateContentType.go +++ b/internal/infrastructure/useCases/CreateContentType.go @@ -37,6 +37,9 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con if err != nil { 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 { return contenttype.ContentType{}, err } diff --git a/internal/infrastructure/useCases/CreateEntry.go b/internal/infrastructure/useCases/CreateEntry.go index a7be5be..810e6b7 100644 --- a/internal/infrastructure/useCases/CreateEntry.go +++ b/internal/infrastructure/useCases/CreateEntry.go @@ -36,6 +36,10 @@ func (u *CreateEntryUseCase) Create(command CreateEntryCommand) (TypedEntry, err if err := ensureMediaExists(u.images, owner, data, nil); err != nil { 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 { return TypedEntry{}, err } @@ -51,5 +55,5 @@ func (u *CreateEntryUseCase) Create(command CreateEntryCommand) (TypedEntry, err if err != nil { return TypedEntry{}, err } - return typedEntry(owner, stored, newMediaResolver(u.images)) + return typedEntry(owner, stored, resolvers) } diff --git a/internal/infrastructure/useCases/DeleteContentType.go b/internal/infrastructure/useCases/DeleteContentType.go index 372c1bb..3a0800a 100644 --- a/internal/infrastructure/useCases/DeleteContentType.go +++ b/internal/infrastructure/useCases/DeleteContentType.go @@ -19,6 +19,15 @@ func (u *DeleteContentTypeUseCase) Delete(key string) error { if err != nil { 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) if err != nil { return err diff --git a/internal/infrastructure/useCases/GetEntry.go b/internal/infrastructure/useCases/GetEntry.go index 4e8b464..2468f70 100644 --- a/internal/infrastructure/useCases/GetEntry.go +++ b/internal/infrastructure/useCases/GetEntry.go @@ -25,5 +25,5 @@ func (u *GetEntryUseCase) Get(contentTypeKey string, id uint32) (TypedEntry, err if err != nil { return TypedEntry{}, err } - return typedEntry(owner, found, newMediaResolver(u.images)) + return typedEntry(owner, found, newEntryResolvers(u.images, u.contentTypes, u.entries)) } diff --git a/internal/infrastructure/useCases/ListEntries.go b/internal/infrastructure/useCases/ListEntries.go index 2ac00f9..e70220b 100644 --- a/internal/infrastructure/useCases/ListEntries.go +++ b/internal/infrastructure/useCases/ListEntries.go @@ -4,6 +4,7 @@ import ( "RenewCMS/internal/domain/contenttype" "RenewCMS/internal/domain/entry" "RenewCMS/internal/domain/image" + "strings" ) type ListEntriesUseCase struct { @@ -17,6 +18,7 @@ type ListEntriesCommand struct { Page int Size int Sort entry.Sort + Query string } type EntryPage struct { @@ -41,12 +43,12 @@ func (u *ListEntriesUseCase) ListPage(command ListEntriesCommand) (EntryPage, er if err != nil { 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 { return EntryPage{}, err } items := make([]TypedEntry, 0, len(found.Items)) - resolver := newMediaResolver(u.images) + resolver := newEntryResolvers(u.images, u.contentTypes, u.entries) for _, record := range found.Items { typed, err := typedEntry(owner, record, resolver) 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 } + +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 +} diff --git a/internal/infrastructure/useCases/UpdateContentType.go b/internal/infrastructure/useCases/UpdateContentType.go index 92631a8..85653c2 100644 --- a/internal/infrastructure/useCases/UpdateContentType.go +++ b/internal/infrastructure/useCases/UpdateContentType.go @@ -39,6 +39,12 @@ func (u *UpdateContentTypeUseCase) Update(command UpdateContentTypeCommand) (con if err != nil { 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) if err != nil { return contenttype.ContentType{}, err diff --git a/internal/infrastructure/useCases/UpdateEntry.go b/internal/infrastructure/useCases/UpdateEntry.go index b9604dc..630772a 100644 --- a/internal/infrastructure/useCases/UpdateEntry.go +++ b/internal/infrastructure/useCases/UpdateEntry.go @@ -40,6 +40,10 @@ func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, err if err := ensureMediaExists(u.images, owner, command.Data, current.Data); err != nil { 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 { return TypedEntry{}, err } @@ -50,5 +54,5 @@ func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, err if err != nil { return TypedEntry{}, err } - return typedEntry(owner, stored, newMediaResolver(u.images)) + return typedEntry(owner, stored, resolvers) } diff --git a/internal/infrastructure/useCases/entryRelation.go b/internal/infrastructure/useCases/entryRelation.go new file mode 100644 index 0000000..ebf2451 --- /dev/null +++ b/internal/infrastructure/useCases/entryRelation.go @@ -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 +} diff --git a/internal/infrastructure/useCases/entrySupport.go b/internal/infrastructure/useCases/entrySupport.go index 48f2db3..6d70f87 100644 --- a/internal/infrastructure/useCases/entrySupport.go +++ b/internal/infrastructure/useCases/entrySupport.go @@ -11,15 +11,29 @@ type TypedEntry struct { entry.Entry ContentTypeKey string 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) - media, err := resolver.resolve(owner, record) + media, err := resolvers.media.resolve(owner, record) if err != nil { 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 { diff --git a/test/custom_content_types_support_http_test.go b/test/custom_content_types_support_http_test.go index 9ff5546..299664a 100644 --- a/test/custom_content_types_support_http_test.go +++ b/test/custom_content_types_support_http_test.go @@ -35,7 +35,7 @@ const supportRecipeKey = "recipe" 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 { t.Helper() diff --git a/test/custom_content_types_test.go b/test/custom_content_types_test.go index 12bbde7..7b875be 100644 --- a/test/custom_content_types_test.go +++ b/test/custom_content_types_test.go @@ -12,8 +12,8 @@ import ( var ( contentTypeKeys = []string{"created_at", "fields", "icon", "id", "key", "name", "updated_at"} - fieldKeys = []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"} - entryKeys = []string{"content_type", "created_at", "created_by", "data", "id", "media", "updated_at", "updated_by"} + fieldKeys = []string{"default", "key", "label", "max", "min", "options", "relation", "required", "type", "unique"} + entryKeys = []string{"content_type", "created_at", "created_by", "data", "id", "media", "relations", "updated_at", "updated_by"} ) func TestCreatingAContentType(t *testing.T) { diff --git a/test/custom_content_types_usecases_test.go b/test/custom_content_types_usecases_test.go index 49971bf..f09b254 100644 --- a/test/custom_content_types_usecases_test.go +++ b/test/custom_content_types_usecases_test.go @@ -628,7 +628,7 @@ func TestContentTypeStoredFormat(t *testing.T) { keys = append(keys, key) } 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) { t.Errorf("stored keys %v", keys) } diff --git a/test/relation_field_type_domain_edges_test.go b/test/relation_field_type_domain_edges_test.go new file mode 100644 index 0000000..28142f5 --- /dev/null +++ b/test/relation_field_type_domain_edges_test.go @@ -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") + } +} diff --git a/test/relation_field_type_http_test.go b/test/relation_field_type_http_test.go new file mode 100644 index 0000000..71cc534 --- /dev/null +++ b/test/relation_field_type_http_test.go @@ -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()) + } + }) +} diff --git a/test/relation_field_type_test.go b/test/relation_field_type_test.go new file mode 100644 index 0000000..0c8584d --- /dev/null +++ b/test/relation_field_type_test.go @@ -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) +} diff --git a/test/relation_field_type_use_case_hardening_test.go b/test/relation_field_type_use_case_hardening_test.go new file mode 100644 index 0000000..29b9c8c --- /dev/null +++ b/test/relation_field_type_use_case_hardening_test.go @@ -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) +} diff --git a/web/src/components/BuilderInspector.vue b/web/src/components/BuilderInspector.vue index f0f581a..3628eb3 100644 --- a/web/src/components/BuilderInspector.vue +++ b/web/src/components/BuilderInspector.vue @@ -29,6 +29,7 @@ const props = defineProps<{ field: BuilderField errors: FieldErrors compact: boolean + ownerName?: string }>() const uid = () => props.field.uid @@ -40,12 +41,15 @@ const uid = () => props.field.uid :errors="errors" :disabled="saving" :compact="compact" + :owner-name="ownerName" :focus-token="focusToken" @label="actions.setLabel(uid(), $event)" @key="actions.setFieldKey(uid(), $event)" @unlock="actions.unlockFieldKey(uid())" @reset="actions.resetFieldKey(uid())" @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 })" @unique="actions.patchField(uid(), { unique: $event })" @limit="(which, value) => actions.setLimit(uid(), which, value)" diff --git a/web/src/components/DeleteTypeDialog.vue b/web/src/components/DeleteTypeDialog.vue index 7155616..efb5b31 100644 --- a/web/src/components/DeleteTypeDialog.vue +++ b/web/src/components/DeleteTypeDialog.vue @@ -11,6 +11,7 @@ const props = defineProps<{ name: string keyValue: string total: number + referrer: string pending: 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') { return { title: t("Couldn't delete the content type"), diff --git a/web/src/components/EntryCardList.vue b/web/src/components/EntryCardList.vue index cc0a3ee..6a1794b 100644 --- a/web/src/components/EntryCardList.vue +++ b/web/src/components/EntryCardList.vue @@ -11,7 +11,9 @@ import { thumbnailColumn, } from '@/lib/entryCells' import { t } from '@/i18n' +import { fieldTypeSpec } from '@/lib/fieldTypes' import { mediaOf } from '@/lib/mediaField' +import { summariesOf } from '@/lib/relationField' import { entryPath } from '@/lib/routePaths' import { computed } from 'vue' @@ -26,11 +28,21 @@ const props = defineProps<{ const shown = computed(() => cardFields(props.fields)) 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) { return field ? 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) { {{ titleOf(entry) }} + + {{ field.label }} + + diff --git a/web/src/components/FieldCell.vue b/web/src/components/FieldCell.vue index 634965d..b93ed8e 100644 --- a/web/src/components/FieldCell.vue +++ b/web/src/components/FieldCell.vue @@ -2,18 +2,23 @@ import { t } from '@/i18n' import { computed } from 'vue' +import RelationChip from '@/components/RelationChip.vue' import type { BuiltinField } from '@/lib/contentTypes' import { EMPTY_TEXT, describeCell, isEmptyCell } from '@/lib/entryCells' import { isMissingImage, type MediaSummary } from '@/lib/mediaField' +import type { RelationSummary } from '@/lib/relationField' const props = defineProps<{ field: BuiltinField value: unknown image?: MediaSummary | null + relations?: RelationSummary[] 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 image = cell.value.image return image && !isMissingImage(image) ? image : null @@ -61,6 +66,21 @@ const TIP_UI = { + + + +{{ cell.more }} + () const emit = defineEmits<{ @@ -37,6 +40,8 @@ const emit = defineEmits<{ unlock: [] reset: [] type: [type: FieldType] + relationType: [key: string] + relationKind: [kind: RelationKind] required: [value: boolean] unique: [value: boolean] limit: [which: 'min' | 'max', value: string] @@ -58,6 +63,7 @@ const showUnique = computed(() => canUnique.value || spec.value.showsUnsupported const showRuleGrid = computed( () => hasLimits.value || spec.value.supportsDefault || spec.value.showsUnsupportedOptions, ) +const holdsRelation = computed(() => spec.value.holdsRelation) const hint = computed(() => spec.value.builderHint?.() ?? '') const keyMode = computed(() => keyDisplayMode(props.field.keyMode, props.field.key)) const gridClass = computed(() => (props.compact ? 'grid-cols-1' : 'md:grid-cols-2')) @@ -193,6 +199,20 @@ watch( @update:model-value="emit('type', $event)" /> + +
+withDefaults(defineProps<{ label: string; missing?: boolean; tall?: boolean }>(), { + missing: false, + tall: false, +}) + + + diff --git a/web/src/components/RelationField.vue b/web/src/components/RelationField.vue new file mode 100644 index 0000000..535b6ef --- /dev/null +++ b/web/src/components/RelationField.vue @@ -0,0 +1,461 @@ + + + diff --git a/web/src/components/RelationPicker.vue b/web/src/components/RelationPicker.vue new file mode 100644 index 0000000..74193a7 --- /dev/null +++ b/web/src/components/RelationPicker.vue @@ -0,0 +1,257 @@ + + + diff --git a/web/src/components/SchemaBuilderPage.vue b/web/src/components/SchemaBuilderPage.vue index 5da40a8..b26064c 100644 --- a/web/src/components/SchemaBuilderPage.vue +++ b/web/src/components/SchemaBuilderPage.vue @@ -262,6 +262,7 @@ async function restoreRowFocus() { :field="builder.selected.value" :errors="selectedErrors" :compact="false" + :owner-name="form.name" />