feat(SPA): media field type
This commit is contained in:
1 parent
a2d596f4ca
commit
3e7fc9a4a5
57 files changed
+6109
-75
No files matched your search
@@ -0,0 +1,492 @@
|
|||||||
|
Feature: Media field type in the SPA
|
||||||
|
As an owner or admin building a content type, and as a verified user filling an entry
|
||||||
|
I want a Media field that holds one image from the media library
|
||||||
|
So that entries link to a library image and always show its current version
|
||||||
|
|
||||||
|
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/media_field_type.feature is done
|
||||||
|
And the design is docs/design/media-field-changes.md sections 1, 2, 3.2 to 3.7, 4, 6 and 7, with the board groups 2 Schema builder, 3 Review and delete, 4 Field types, 5 Content and entry list, 6 Entry editor and 10 Media picker for fields of "RenewCMS Screens Schema.dc.html"
|
||||||
|
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 "media" where a registry property can say it
|
||||||
|
And a media value is the id of a library image (a uint32 of at least 1) or null
|
||||||
|
And every entry answer (POST 201, PUT 200, GET one, each list item) carries a top-level "media" object keyed by media field key: {"id","filename","width","height","size","thumbnail_url"} for an existing image, or {"id","missing":true} when the image is gone; a null value has no key
|
||||||
|
And the API refuses with 422 "invalid_entry: <key>: required" or "invalid_entry: <key>: type", 409 "schema_incompatible" for a new required media field on a type with entries, and 422 "invalid_field" for forbidden options
|
||||||
|
And the picker is the existing ImageUploader with MediaPicker, loading GET /v1/media/?page=&size=&q= with the total in the "counts" and "total" fields as the article picker does; the Sites, Authors, SEO and article pickers are unchanged
|
||||||
|
And the picker in field mode never requests /v1/image-variants and never shows the variant select or the alt text
|
||||||
|
And the form dimming stays the entry form's 70 % (design conflict 12, existing rule kept); inside the control the thumbnail drops to 60 % and the buttons take the disabled look
|
||||||
|
And sentence case, "image" and never "asset", buttons are verbs
|
||||||
|
And every string goes through t() and has a French entry in i18n/messages.ts
|
||||||
|
And a copy tagged "(proposed copy)" is not in the design and is the coder's single point of change
|
||||||
|
And user decisions already taken: Apply is disabled for the certain refusal; thumbnail after Description in schema order; "Clear"; "1600 × 900 · 240 KB"; "Use this image" and "Remove image"; "Pick an image" on optional fields; row heights 48 and 68
|
||||||
|
And out of scope (slice C): "Used in" badge, usage panel and delete warning of the Media library, the read-only view of a custom type, filtering by media
|
||||||
|
|
||||||
|
Scenario Outline: Media in the registry
|
||||||
|
Given the registry entry for "media"
|
||||||
|
Then <property> is <value>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| property | value |
|
||||||
|
| type name in the API | media (the seventh entry, after select) |
|
||||||
|
| label | Media |
|
||||||
|
| description | One image from your media library |
|
||||||
|
| icon | lucide:image |
|
||||||
|
| control | MediaFieldControl |
|
||||||
|
| pairs with a neighbour | no, full width (as text, richtext and select) |
|
||||||
|
| empty value | null |
|
||||||
|
| initial on create | null; a media field has no default, so no "Default" tag ever shows |
|
||||||
|
| initial on edit | the stored id, or null |
|
||||||
|
| supports unique | no |
|
||||||
|
| supports limits | no |
|
||||||
|
| supports default | no |
|
||||||
|
| supports choices | no |
|
||||||
|
| counter | none |
|
||||||
|
| help | One image from your library. |
|
||||||
|
| list cell | thumbnail, column 80 px, not right-aligned, not sortable |
|
||||||
|
| sample in the API preview | null |
|
||||||
|
|
||||||
|
Scenario Outline: Client validation of a media value
|
||||||
|
Given a media field "Thumbnail" that is <required>
|
||||||
|
When the value is <value>
|
||||||
|
Then the rule is <rule>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| required | value | rule |
|
||||||
|
| required | null | required |
|
||||||
|
| required | undefined | required |
|
||||||
|
| optional | null | none |
|
||||||
|
| optional | 7 | none |
|
||||||
|
| required | 7 | none |
|
||||||
|
| optional | 0 | type |
|
||||||
|
| optional | -1 | type |
|
||||||
|
| optional | 7.5 | type |
|
||||||
|
| optional | "7" | type |
|
||||||
|
| optional | true | type |
|
||||||
|
| optional | 4294967296 | type |
|
||||||
|
|
||||||
|
Scenario Outline: Messages of a media field
|
||||||
|
Given a media field labelled "Thumbnail"
|
||||||
|
Then the message of the rule <rule> reads <message>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| rule | message |
|
||||||
|
| required | Pick an image for Thumbnail. |
|
||||||
|
| type | Thumbnail must be an image from the library. |
|
||||||
|
| min | not produced for media |
|
||||||
|
| max | not produced for media |
|
||||||
|
| options | not produced for media |
|
||||||
|
| unique | not produced for media |
|
||||||
|
|
||||||
|
Scenario: A loaded media field is not shown as Text
|
||||||
|
Given GET /v1/content-type/events returns a field {"key":"thumbnail","label":"Thumbnail","type":"media","required":false,"unique":false,"default":null,"min":null,"max":null,"options":[]}
|
||||||
|
Then the builder, the entry form and the list resolve it to the media entry of the registry, never to the fallback type "text"
|
||||||
|
|
||||||
|
# ---------- Schema builder (groups 2 and 4)
|
||||||
|
|
||||||
|
Scenario: Seven type tiles
|
||||||
|
Given the inspector of a field that can still change type
|
||||||
|
Then seven radio tiles are shown in this order, each with its icon, name and description
|
||||||
|
| tile | icon | description |
|
||||||
|
| Text | type | Short text, like a title or a name |
|
||||||
|
| Rich text | pilcrow | Long text with formatting, written in Markdown |
|
||||||
|
| Number | hash | Whole or decimal numbers |
|
||||||
|
| Yes or no | toggle-left | An on or off switch |
|
||||||
|
| Date | calendar | A calendar day, without a time |
|
||||||
|
| Choice | list | One option from a list you define |
|
||||||
|
| Media | image | One image from your media library |
|
||||||
|
And the grid has 3 columns in the desktop inspector and 2 in the mobile sheet
|
||||||
|
|
||||||
|
Scenario: Adding a media field (design "New media field, seven types")
|
||||||
|
Given Events with 7 saved fields and 37 entries
|
||||||
|
When the user activates "Add field", types the label "Thumbnail" and chooses the tile "Media"
|
||||||
|
Then the new row shows the badge "New", the key "thumbnail" (Auto, editable) with the help "The name used in the API. Starts with a letter. Lowercase letters, digits and underscores."
|
||||||
|
And the seven tiles are all enabled with Media selected and the legend "Type" has no lock
|
||||||
|
And the chip reads "Unsaved changes"
|
||||||
|
And the row is: grip, a 28 px square holding the image icon, the label, the key in mono and the badge "New"
|
||||||
|
|
||||||
|
Scenario: The inspector of a media field shows only what applies (design "Media field selected")
|
||||||
|
Given Events with a saved optional media field "Thumbnail" as its eighth field
|
||||||
|
When the user selects the row "Thumbnail"
|
||||||
|
Then the inspector reads "Field: Thumbnail" and holds, in this order, the Label input, the locked Key "thumbnail" with "Used in the API, so it can't change.", the Type tiles, the switch "Required" with "An entry can't be saved with this field empty." and the hint block
|
||||||
|
And the legend of the tiles reads "Type" with a lock and "Can't change after saving", Media is selected and the six other tiles are dimmed to 40 % and not selectable
|
||||||
|
And the hint block is info-soft with an info icon and reads "Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version."
|
||||||
|
And "Unique values", "Default value", the limit inputs, the "Limits" heading and the choice list are not rendered at all
|
||||||
|
|
||||||
|
Scenario: Changing a new row to and from Media
|
||||||
|
Given a new row of type Text, required, with a Longest of 80
|
||||||
|
When the user chooses the tile "Media"
|
||||||
|
Then the label, the key and Required are kept and unique, default, limits and choices are reset
|
||||||
|
When the user then chooses the tile "Number"
|
||||||
|
Then the media hint is gone and the number controls show with no stale value
|
||||||
|
|
||||||
|
Scenario: A required media field (design "Media field, required")
|
||||||
|
Given a saved media field "Thumbnail" that is required
|
||||||
|
Then its row carries the existing Required marker "R" (accessible name "Required") next to the key
|
||||||
|
And its inspector shows Required switched on
|
||||||
|
|
||||||
|
Scenario: The PUT and POST body of a media field
|
||||||
|
Given a media field with the label "Thumbnail", the key "thumbnail" and Required off
|
||||||
|
When the type is saved
|
||||||
|
Then the field is sent as {"key":"thumbnail","label":"Thumbnail","type":"media","required":false,"unique":false,"default":null,"min":null,"max":null,"options":[]}
|
||||||
|
And a round trip of the loaded field to the payload produces no change
|
||||||
|
|
||||||
|
Scenario: Media in the API preview
|
||||||
|
Given the builder holds a media field "Thumbnail"
|
||||||
|
Then the sample entry shows "thumbnail": null and no "Example text"
|
||||||
|
|
||||||
|
Scenario: The mobile sheet of a media field
|
||||||
|
Given a 390 px viewport
|
||||||
|
When the user selects the row "Thumbnail"
|
||||||
|
Then the 92 % sheet titled "Field: Thumbnail" shows the same inspector in one column with the type tiles in 2 columns and the footer button "Done"
|
||||||
|
|
||||||
|
Scenario: The field-type gallery (group 4) has no SPA screen
|
||||||
|
Then the registry facts above are covered by the registry scenarios and no route renders the gallery frame
|
||||||
|
|
||||||
|
# ---------- Review (group 3)
|
||||||
|
|
||||||
|
Scenario: A required media field added to a type with entries is refused first (design "Review: required media field refused")
|
||||||
|
Given Events with 12 entries and a new media field "Thumbnail" that is required
|
||||||
|
When the user activates Save
|
||||||
|
Then no PUT is sent
|
||||||
|
And the dialog "Review changes to Events" with "Apply them to save the content type." opens (a bottom sheet on mobile)
|
||||||
|
And the summary reads "Add 1 field" and "Events has 12 entries. Each one is checked against the new fields when you apply."
|
||||||
|
And the row "Add Thumbnail" has the warning badge "Can't be applied" and the text "Media, required. A media field can't have a default, so the 12 existing entries would have no image. Make Thumbnail optional to add it."
|
||||||
|
And the note "Back to editing keeps your other changes." shows instead of the "Flagged changes can be refused..." note
|
||||||
|
And "Back to editing" is enabled and "Apply changes" is disabled (aria-disabled), so pressing it sends nothing
|
||||||
|
|
||||||
|
Scenario: The refusal is only for a new required media field with entries
|
||||||
|
Given Events with <entries> entries and the edit <edit>
|
||||||
|
When the user activates Save
|
||||||
|
Then <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| entries | edit | result |
|
||||||
|
| 12 | adds Thumbnail, Media, optional | the review opens, "Apply changes" is enabled |
|
||||||
|
| 12 | adds Thumbnail, Media, required | the review opens, "Apply changes" is disabled |
|
||||||
|
| 0 | adds Thumbnail, Media, required | no review, the PUT is sent at once |
|
||||||
|
| 12 | makes the saved optional media field Thumbnail required | the review opens, row "Change Thumbnail" "Now required." flagged "Entries may not fit", Apply enabled; the server decides |
|
||||||
|
| 12 | adds Thumbnail required and changes Capacity | both rows listed, Apply disabled |
|
||||||
|
|
||||||
|
Scenario Outline: The clean row (design "Review: optional media field")
|
||||||
|
Given Events with <entries> entries and a new optional media field "Thumbnail"
|
||||||
|
Then the row "Add Thumbnail" reads "<text>" with no flag
|
||||||
|
And "Apply changes" is enabled
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| entries | text |
|
||||||
|
| 12 | Media, optional. 12 entries are unaffected. |
|
||||||
|
| 1 | Media, optional. 1 entry is unaffected. (proposed copy) |
|
||||||
|
|
||||||
|
Scenario: Back to editing after the refusal
|
||||||
|
Given the refused review is open
|
||||||
|
When the user chooses "Back to editing" (or presses Esc or the close icon)
|
||||||
|
Then the dialog closes, every edit is kept and no request was sent
|
||||||
|
When the user turns Required off on the new row and activates Save
|
||||||
|
Then the review opens with the clean row and "Apply changes" enabled
|
||||||
|
|
||||||
|
Scenario: The refusal follows offline and applying
|
||||||
|
Given the refused review is open
|
||||||
|
Then "Apply changes" stays disabled whatever the connection
|
||||||
|
And with a clean review, "Apply changes" is still disabled while offline, as before
|
||||||
|
|
||||||
|
Scenario: The server refuses anyway
|
||||||
|
Given the entry count was stale and the client opened no refusal
|
||||||
|
When the PUT answers 409 "schema_incompatible"
|
||||||
|
Then the existing "Nothing was changed" refusal flow of features/schema_builder_edit.feature runs unchanged
|
||||||
|
|
||||||
|
Scenario: 422 invalid_field on a media field
|
||||||
|
Given the PUT answers 422 "invalid_field"
|
||||||
|
Then the existing invalid-field failure of the builder is shown and the edits stay
|
||||||
|
|
||||||
|
# ---------- MediaFieldControl in the entry editor (group 6)
|
||||||
|
|
||||||
|
Scenario: Where the control sits
|
||||||
|
Given Events with the fields Title, Starts on, Ends on, Online, Capacity, Category, Description and Thumbnail
|
||||||
|
Then the control for Thumbnail is the eighth, after Description, full width in the 760 px column, and the footer lines come after it
|
||||||
|
|
||||||
|
Scenario: Empty, optional (design "Media control: empty, optional")
|
||||||
|
Given /content/events/new, or an entry whose thumbnail is null
|
||||||
|
Then the label reads "Thumbnail"
|
||||||
|
And a dashed box (1 px, border-accented, padding 12) holds a 40 px square with the image icon, the text "No image selected." and the outline button "Pick an image" with the image-plus icon
|
||||||
|
And the help line reads "One image from your library."
|
||||||
|
And on mobile the box stacks and the button is 44 px high
|
||||||
|
|
||||||
|
Scenario: Empty, required (design "Media control: empty, required")
|
||||||
|
Given the field is required
|
||||||
|
Then the label reads "Thumbnail *" with the asterisk hidden from assistive technology, and the rest is as the empty state
|
||||||
|
|
||||||
|
Scenario: Filled (design "Media control: filled")
|
||||||
|
Given an entry whose thumbnail is 7 and media.thumbnail is {"id":7,"filename":"lime-plaster-workshop.jpg","width":1600,"height":900,"size":245760,"thumbnail_url":"/media/lime-plaster-workshop.jpg"}
|
||||||
|
Then a solid 1 px box (border-accented, padding 12) holds a 160 × 120 thumbnail button showing thumbnail_url, the filename in weight 500, "1600 × 900 · 240 KB" in 12/18 muted, the outline button "Change image" (image icon) and the ghost button "Remove" (x icon)
|
||||||
|
And the help line reads "One image from your library."
|
||||||
|
And on mobile the thumbnail is full width at 16:9 and the two buttons are full width and 44 px high
|
||||||
|
And a width or height of null (a vector image) reads "Vector · 8 KB"
|
||||||
|
|
||||||
|
Scenario: Hover (design "Media control: hover")
|
||||||
|
Given the filled state on desktop
|
||||||
|
When the pointer is over the thumbnail
|
||||||
|
Then the box border takes the --ui-text-muted colour and a 28 px bar at the bottom of the thumbnail (--ui-bg-inverted, --ui-text-inverted) reads "Change image" with the image icon at 14 px
|
||||||
|
And mobile has no hover state and shows the filled state
|
||||||
|
|
||||||
|
Scenario: Focus (design "Media control: focus")
|
||||||
|
Given the filled state
|
||||||
|
When the keyboard focus reaches the thumbnail button
|
||||||
|
Then it has a 2 px --ui-primary outline with a 2 px offset
|
||||||
|
|
||||||
|
Scenario: Pointer and keyboard open the picker
|
||||||
|
Given the filled state
|
||||||
|
When the user activates the thumbnail, "Change image", or on an empty control "Pick an image"
|
||||||
|
Then the picker opens in field mode and nothing else changes
|
||||||
|
|
||||||
|
Scenario: Remove
|
||||||
|
Given the filled state
|
||||||
|
When the user activates "Remove"
|
||||||
|
Then the value becomes null, the control shows the empty state and the chip reads "Unsaved changes"
|
||||||
|
And the live region says "Image removed from Thumbnail." and the focus is on "Pick an image"
|
||||||
|
|
||||||
|
Scenario: Missing image, optional (design "Media control: image no longer in the library")
|
||||||
|
Given media.thumbnail is {"id":7,"missing":true} and the field is optional
|
||||||
|
Then the box is solid with a dashed 160 × 120 placeholder (16:9 on mobile) holding image-off at 24 px, with role "img" and the name "Image no longer in the library"
|
||||||
|
And beside it "This image is no longer in the library" in weight 500, the help "You can still save. The entry keeps the link until you change it." in 12/18 muted, the outline button "Choose another image" and the ghost button "Clear"
|
||||||
|
And there is no help line under the box
|
||||||
|
And "Save" is allowed and the data sent keeps thumbnail 7
|
||||||
|
|
||||||
|
Scenario: Clear
|
||||||
|
Given the missing state
|
||||||
|
When the user activates "Clear"
|
||||||
|
Then the value becomes null, the live region says "Thumbnail cleared." and the focus is on "Pick an image"
|
||||||
|
|
||||||
|
Scenario: Missing image, required (design "Media control: missing image, required")
|
||||||
|
Given media.thumbnail is {"id":7,"missing":true} and the field is required
|
||||||
|
Then the in-box help reads "Thumbnail is required. Choose another image to save."
|
||||||
|
And when the user saves, no request is sent, the box takes the error border, the line "Pick an image for Thumbnail." shows with a circle-alert icon, the chip reads "Not saved" and the focus is on "Choose another image"
|
||||||
|
|
||||||
|
Scenario: Validation error on an empty required field (design "Media control: validation error")
|
||||||
|
Given an empty required media field
|
||||||
|
When the user activates "Save"
|
||||||
|
Then no request is sent
|
||||||
|
And the box border is --ui-error with an inset 1 px --ui-error, the error line "Pick an image for Thumbnail." with circle-alert at 14 px replaces the help line, and the chip reads "Not saved"
|
||||||
|
And the focus moves to "Pick an image" and the page scrolls to it
|
||||||
|
And only the first field in schema order with an error takes the focus
|
||||||
|
|
||||||
|
Scenario: Picking clears the error
|
||||||
|
Given the validation error is shown
|
||||||
|
When the user picks an image
|
||||||
|
Then the error and the error border go, the chip reads "Unsaved changes" and nothing else is validated
|
||||||
|
|
||||||
|
Scenario: Saving (design "Media control: saving")
|
||||||
|
Given the PUT has not answered
|
||||||
|
Then the form is dimmed to 70 %, the thumbnail is at 60 %, the buttons take the disabled look, the thumbnail button and the buttons are not operable and the picker cannot open
|
||||||
|
And Save reads "Saving…"
|
||||||
|
|
||||||
|
Scenario: Read-only after a schema change (design "Media control: read-only, type changed")
|
||||||
|
Given the PUT answers 422 "invalid_entry: thumbnail: unknown_field" and thumbnail is a media field of the loaded type
|
||||||
|
Then a warning alert "The fields of Events changed" reads "The Thumbnail field was changed after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields." with the action "Reload form"
|
||||||
|
And the control looks as in Saving and cannot be operated
|
||||||
|
And "Reload form" fetches the type and the entry again and rebuilds the form with the control from the answered media map
|
||||||
|
And any other key keeps the copy of features/content_entry_editor.feature state 12
|
||||||
|
|
||||||
|
Scenario Outline: What the control shows and sends
|
||||||
|
Given the loaded value <loaded> with media <media>
|
||||||
|
When the user <action> and activates "Save"
|
||||||
|
Then the control shows <shown>
|
||||||
|
And the data holds "thumbnail": <sent>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| loaded | media | action | shown | sent |
|
||||||
|
| null | none | saves with nothing changed | the empty state | null |
|
||||||
|
| 7 | existing image 7 | saves with nothing changed | the filled state | 7 |
|
||||||
|
| 7 | missing image 7 (optional) | saves with nothing changed | the missing state | 7 |
|
||||||
|
| 7 | existing image 7 | picks image 8 | image 8 (its own library data) | 8 |
|
||||||
|
| 7 | existing image 7 | activates Remove | the empty state | null |
|
||||||
|
| 7 | missing image 7 | activates Clear | the empty state | null |
|
||||||
|
| null | none | picks image 8 | image 8 | 8 |
|
||||||
|
|
||||||
|
Scenario: Right after a pick the control uses the picked library item
|
||||||
|
Given the user picks "bathroom-tiles.png" (id 8, 800 × 600, 51200 bytes) from the library
|
||||||
|
Then before any save the control shows its filename, "800 × 600 · 50 KB" and its url as the thumbnail
|
||||||
|
And no request is made for it
|
||||||
|
When the PUT answers 200 with media.thumbnail for image 8
|
||||||
|
Then the control is rebuilt from the answered media map
|
||||||
|
|
||||||
|
Scenario: Dirty tracking of a media value
|
||||||
|
Given an entry whose thumbnail is 7
|
||||||
|
When the user picks image 8 then picks image 7 again
|
||||||
|
Then the chip reads "Saved", "Save" is disabled and the leave guard does not prompt
|
||||||
|
When the user picks image 8
|
||||||
|
Then the chip reads "Unsaved changes" and the leave guard prompts
|
||||||
|
|
||||||
|
Scenario Outline: Server answers for a media field
|
||||||
|
Given the client found no error and the PUT or POST is sent
|
||||||
|
When the server answers <status> "<body>"
|
||||||
|
Then <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| status | body | result |
|
||||||
|
| 422 | invalid_entry: thumbnail: required| "Pick an image for Thumbnail." under the control, error border, chip "Not saved", focus on the first button |
|
||||||
|
| 422 | invalid_entry: thumbnail: type | "Thumbnail must be an image from the library." under the control, chip "Not saved", focus on "Change image" or "Pick an image" (the image was deleted between the pick and the save) |
|
||||||
|
| 409 | schema_incompatible | not an entry answer; never produced by the entry routes |
|
||||||
|
|
||||||
|
Scenario: Create payload with a media field
|
||||||
|
Given /content/events/new with Title "Open day", Starts on 2026-11-21 and the media field left empty
|
||||||
|
When the user activates "Save"
|
||||||
|
Then POST /v1/content-type/events/entry is sent once with "thumbnail": null among the other keys, never absent
|
||||||
|
And with image 8 picked it holds "thumbnail": 8, a JSON number
|
||||||
|
|
||||||
|
Scenario: The title of an entry whose first field is media
|
||||||
|
Given a type whose first field is a media field
|
||||||
|
Then the page title, the breadcrumb and the saved toast read "Entry 42" (an id is not a title) and the list card title reads the same (proposed)
|
||||||
|
|
||||||
|
Scenario: Layout of the generated form with a media field
|
||||||
|
Given a boolean, a number or a date field before the media field
|
||||||
|
Then the media field never pairs and is full width at every viewport
|
||||||
|
|
||||||
|
# ---------- Picker in field mode (group 10)
|
||||||
|
|
||||||
|
Scenario: Opening the picker from an optional field (design "Library: nothing chosen")
|
||||||
|
Given the control holds image 1 "north-wall.jpg" and the field is optional
|
||||||
|
When the user activates "Change image"
|
||||||
|
Then the dialog (640 px wide on desktop, a bottom sheet on mobile) is titled "Choose an image" and has the tabs "Upload" and "Library", opening on Library
|
||||||
|
And the search reads "Search by filename"
|
||||||
|
And the first row of the Library tab is "No image" with "Leave this field empty."
|
||||||
|
And the summary reads "Newest first · 48 images", the tiles are square, 4 columns on desktop and 3 on mobile, and "Load more" shows
|
||||||
|
And the tile of image 1 carries the badge "Current" (sand fill, --rc-on-sand text, 18 px high, bottom left)
|
||||||
|
And "Cancel" is enabled and "Use this image" is disabled (aria-disabled)
|
||||||
|
And there is no alt text field, no variant select, and no request to /v1/image-variants
|
||||||
|
|
||||||
|
Scenario: Choosing an image (design "Library: image chosen")
|
||||||
|
When the user selects a tile
|
||||||
|
Then it gets the primary outline with a check at top right, aria-pressed is true, and a strip appears below the grid with a 56 px thumbnail, the filename and "dimensions · size"
|
||||||
|
And the grid shrinks and "Load more" is hidden while a tile is selected, as in the article picker
|
||||||
|
And "Use this image" is enabled
|
||||||
|
When the user activates it
|
||||||
|
Then the dialog closes, the value becomes that image's id, the control shows its data, the live region says "Image selected: <filename>" and the focus is on "Change image"
|
||||||
|
|
||||||
|
Scenario: Choosing "No image" (design "Library: No image chosen")
|
||||||
|
When the user selects the row "No image"
|
||||||
|
Then it takes the sand fill, the primary border and a check, with aria-pressed true, and any selected tile is released
|
||||||
|
And the primary button reads "Remove image"
|
||||||
|
When the user activates it
|
||||||
|
Then the value becomes null, the live region says "Image removed from Thumbnail." and the focus is on "Pick an image"
|
||||||
|
|
||||||
|
Scenario: A required field has no "No image" row (design "Library: required field")
|
||||||
|
Given the field is required
|
||||||
|
Then the Library tab shows no "No image" row and the title is still "Choose an image"
|
||||||
|
|
||||||
|
Scenario: Choosing the current image again
|
||||||
|
Given the control holds image 1
|
||||||
|
When the user selects the tile "Current" and activates "Use this image"
|
||||||
|
Then the dialog closes, the value does not change, the chip stays as it was, and no announcement is made (proposed)
|
||||||
|
|
||||||
|
Scenario: Empty library (design "Library: empty")
|
||||||
|
Given GET /v1/media/ returns no image
|
||||||
|
Then the picker opens on the Upload tab unless the user already chose a tab, and the Library tab reads "Your library is empty" / "Images you upload appear here, ready to use in any article or entry." with "Upload an image"
|
||||||
|
And "Upload an image" switches to the Upload tab
|
||||||
|
And the article picker keeps "Images you upload appear here, ready to use in any article."
|
||||||
|
|
||||||
|
Scenario Outline: Library failures (design "Library: failed to load")
|
||||||
|
Given GET /v1/media/ answers <outcome>
|
||||||
|
Then <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| outcome | result |
|
||||||
|
| 500 | the error alert "Couldn't load your library" / "Check your connection and try again." with "Try again", no "No image" row |
|
||||||
|
| a network error | the same |
|
||||||
|
| 429 | the countdown alert of the Media page |
|
||||||
|
| 401 | the global redirect, no local copy |
|
||||||
|
|
||||||
|
Scenario: Try again
|
||||||
|
Given the failed state
|
||||||
|
When the user activates "Try again"
|
||||||
|
Then only GET /v1/media/ is sent again and the grid replaces the alert
|
||||||
|
|
||||||
|
Scenario: Loading (design "Library: loading")
|
||||||
|
Given GET /v1/media/ has not answered
|
||||||
|
Then after 150 ms skeleton tiles show (8 on desktop, 9 on mobile), no "No image" row shows and "Use this image" is disabled
|
||||||
|
|
||||||
|
Scenario: Search and no results
|
||||||
|
When the user types "wall"
|
||||||
|
Then the search is trimmed and debounced, the selection is released and the grid reloads
|
||||||
|
And an empty match reads "No images match \"wall\"" with "Clear search", as in the article picker
|
||||||
|
|
||||||
|
Scenario: Load more
|
||||||
|
When the user activates "Load more"
|
||||||
|
Then the next page loads, the focus stays on the button until the new tiles arrive and then moves to the first new tile
|
||||||
|
|
||||||
|
Scenario: Upload tab (design "Upload tab")
|
||||||
|
When the user opens the Upload tab
|
||||||
|
Then the existing flow runs unchanged: "Drop an image here, or choose a file", "PNG, JPEG, WEBP, SVG · max 10 MB", crop and resize, the duplicate check "Already in your library" with "Use existing" and "Upload anyway"
|
||||||
|
And a finished upload lands in the library and becomes the value of the field, as in the Sites and Authors pickers, then the dialog closes and the live region says "Image selected: <filename>" (to confirm, decision D5)
|
||||||
|
|
||||||
|
Scenario Outline: Upload failures keep the existing copy
|
||||||
|
Given the POST /v1/media/ answers <outcome>
|
||||||
|
Then <copy> shows and nothing is chosen
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| outcome | copy |
|
||||||
|
| 413 | That file is too large. |
|
||||||
|
| 400 | That request wasn't valid. Check the fields and try again. |
|
||||||
|
| 429 | Too many requests. Try again in a few minutes. |
|
||||||
|
| 500 | Something went wrong on our side. Nothing was changed. Try again in a moment. |
|
||||||
|
|
||||||
|
Scenario: Cancel and Escape
|
||||||
|
When the user activates "Cancel" or presses Esc or the close icon
|
||||||
|
Then nothing changes and the focus returns to the button that opened the picker
|
||||||
|
|
||||||
|
Scenario: Tile names in field mode
|
||||||
|
Then each tile is a button named "stone-steps.jpg, 310 KB" and "stone-steps.jpg, 310 KB, Current" for the current image, with aria-pressed, and no "used in" part
|
||||||
|
And "No image" is a button with aria-pressed and "Use this image" uses aria-disabled while nothing is chosen
|
||||||
|
|
||||||
|
Scenario: The other pickers are unchanged
|
||||||
|
Then the article picker still reads "Add an image", "Insert image", "In this article", the alt text and the variant select
|
||||||
|
And the Sites, Authors and SEO pickers still read "Use image" with no strip and no "No image" row
|
||||||
|
|
||||||
|
# ---------- Entry list (group 5), minimal safe cell
|
||||||
|
|
||||||
|
Scenario: The Thumbnail column (design "Thumbnail column")
|
||||||
|
Given Events with a Thumbnail media field, the columns Title, Thumbnail, Starts on and Category chosen, and the items 42 and 41 with an existing image, 40 and 37 with null, 39 with a missing image
|
||||||
|
Then the header reads "Thumbnail" with no sort icon and no aria-sort, in a column 80 px wide
|
||||||
|
And an existing image shows a 40 px square thumbnail from media.thumbnail_url, with 4 px above and below, so the row stays 48 px
|
||||||
|
And a null value shows "–" in the muted colour
|
||||||
|
And a missing image shows a dashed 40 px square with the unlink icon, role "img" and the name "Image no longer in the library"
|
||||||
|
And the thumbnail is read from the item's own "media" object, with no extra request
|
||||||
|
|
||||||
|
Scenario: Tooltip of the missing placeholder
|
||||||
|
Given a missing image in the list on desktop
|
||||||
|
When the pointer or the focus reaches the dashed square
|
||||||
|
Then a tooltip reads "Image no longer in the library"
|
||||||
|
And mobile has no tooltip
|
||||||
|
|
||||||
|
Scenario: The mobile card (design "Thumbnail column", mobile)
|
||||||
|
Given a 390 px viewport and the Thumbnail column chosen
|
||||||
|
Then a 32 px thumbnail leads each card, "–" stands for none, a dashed 32 px square for a missing image, and the card keeps its 68 px minimum height
|
||||||
|
And when no media field is among the chosen columns the card is unchanged
|
||||||
|
|
||||||
|
Scenario: Columns menu (design "Columns menu with Thumbnail")
|
||||||
|
When the user opens "Columns"
|
||||||
|
Then "Thumbnail" is listed with the image icon, its key "thumbnail", and a checkbox like the other fields
|
||||||
|
And the summary reads "4 of 6" and on mobile "Choose up to 6 fields to show. 4 selected."
|
||||||
|
And the cap of 6, Reset and the stored choice work as for any field
|
||||||
|
|
||||||
|
Scenario: Thumbnail as the first column
|
||||||
|
Given a type whose first field is a media field
|
||||||
|
Then the first column still links to the editor and the thumbnail is the link content
|
||||||
|
|
||||||
|
Scenario: Plain text of a media cell
|
||||||
|
Given the card or a title needs the text of a media cell
|
||||||
|
Then it reads "–" for none and never the numeric id
|
||||||
|
|
||||||
|
# ---------- Accessibility (media-field-changes.md section 6)
|
||||||
|
|
||||||
|
Scenario: Accessibility of the control
|
||||||
|
Then the control is role="group" labelled by the field label
|
||||||
|
And the main button (Pick an image, or Change image) carries aria-required when the field is required
|
||||||
|
And the accessible names are "Pick an image for Thumbnail", "Change image, lime-plaster-workshop.jpg, 1600 by 900, 240 KB" (the thumbnail), "Remove image from Thumbnail", "Clear Thumbnail" and, for the placeholder, role "img" "Image no longer in the library"
|
||||||
|
And the error text has an id, and the main button gets aria-invalid="true" and aria-describedby with the error id then the help id
|
||||||
|
And the tab order is label, thumbnail, Change image, Remove, then help or error (two stops open the same picker, as drawn; decision D8)
|
||||||
|
And one polite live region announces "Image selected: lime-plaster-workshop.jpg", "Image removed from Thumbnail." and "Thumbnail cleared."; nothing is announced when the picker opens
|
||||||
|
And focus goes to the first invalid control on a refused save and, after the picker, to the places of the picker scenarios
|
||||||
|
And mobile targets are 44 px and there is no hover on mobile
|
||||||
|
|
||||||
|
Scenario: Accessibility of the builder and the review
|
||||||
|
Then the Media tile is a radio in the existing radio group, the dimmed tiles are disabled, "Can't be applied" is a text badge and not only a colour, and the disabled "Apply changes" is announced as disabled
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# QA: Media field type in the SPA (slice B)
|
||||||
|
|
||||||
|
Runner: Vitest + Vue Test Utils (`task test:web`). No Go test: the backend is covered by features/media_field_type.qa.md and nothing changes in it. Kit: extend `views/__tests__/schemaTestKit.ts` with the Events type (7 fields, plus Thumbnail as the 8th, optional or required, saved or new), entries whose `data.thumbnail` and top-level `media` vary, the library list (`GET /v1/media/?page=&size=&q=` with `items`, `total`, `counts`), and the plain-text bodies `invalid_entry: thumbnail: required`, `invalid_entry: thumbnail: type`, `invalid_entry: thumbnail: unknown_field`, `schema_incompatible`, `invalid_field`. Library fixtures: 1 "north-wall.jpg", 7 "lime-plaster-workshop.jpg" (1600 × 900, 245760 bytes, shown "240 KB"), 8 "bathroom-tiles.png" (800 × 600, 51200 bytes, "50 KB"), a vector image with null width and height. Pin the time zone; never freeze the system date. No state of this slice depends on the clock.
|
||||||
|
|
||||||
|
## Automated
|
||||||
|
|
||||||
|
Registry and pure logic (lib/__tests__, it.each over the feature's Examples):
|
||||||
|
1. Registry `media`: label, description, icon, control, no pairing, empty value, initial on create and edit, no unique, limits, default or choices, counter none, help, cell, API preview sample null. A loaded field of type `media` never resolves to the `text` fallback in the builder, the form or the list. The six existing types still pass every existing registry assertion unchanged (update the count assertions from six to seven).
|
||||||
|
2. Validation table: required and optional with null, undefined, 7, 0, -1, 7.5, "7", true, 4294967296. A required field whose image is missing is `required` (registry takes the image summary, or the form passes it).
|
||||||
|
3. Messages for required and type with the label "Thumbnail"; min, max, options and unique are never produced.
|
||||||
|
4. Payload: id to id, null to null, an optional missing stored id is sent unchanged, never absent. The builder payload of a media field equals `{key,label,type:'media',required,unique:false,default:null,min:null,max:null,options:[]}`; a loaded media field round-trips with no diff.
|
||||||
|
5. Builder model: `changeFieldType` to and from media resets unique, default, limits and options and keeps label, key and required. Sample preview value is null.
|
||||||
|
6. Review diff: the add row of a media field, required with total > 0 is flagged "Can't be applied" with the exact text; optional reads "Media, optional. N entries are unaffected." (plural rule for 1); total 0 gives no review; making a saved media field required stays "Now required." with "Entries may not fit". Summary line is unchanged.
|
||||||
|
7. Entry helpers: `entryTitle` with a media first field gives "Entry N"; `describeCell` and `plainCellText` give "–" for none and never the id; media summary parsing for existing, missing and absent keys; `formatDimensions` and `formatSize` give "1600 × 900" and "240 KB" for 245760 bytes and "Vector" for null dimensions.
|
||||||
|
|
||||||
|
Schema builder (views/__tests__/SchemaBuilderMedia*.spec.ts):
|
||||||
|
8. Seven tiles in order with icon, name and description; 3 columns in the desktop inspector, 2 in the sheet.
|
||||||
|
9. New media field: "New" badge, key editable with its help, seven tiles enabled, chip "Unsaved changes".
|
||||||
|
10. Saved media field: key locked with its help, legend lock and "Can't change after saving", six tiles dimmed and unselectable, hint block copy, Required switch and copy, and that "Unique values", "Default value", limit inputs, "Limits" heading and choice list are absent from the DOM.
|
||||||
|
11. Required marker on the row; row anatomy (28 px image square, label, key, badges).
|
||||||
|
12. Mobile sheet: title "Field: Thumbnail", footer "Done", one column.
|
||||||
|
13. Save paths: PUT and POST bodies; 422 invalid_field and 409 schema_incompatible keep their existing flows.
|
||||||
|
|
||||||
|
Review (views/__tests__/SchemaBuilderMediaReview.spec.ts):
|
||||||
|
14. Refusal: dialog copy, summary, row, badge, note "Back to editing keeps your other changes.", Apply `aria-disabled` and a click sends no PUT; Back to editing, Esc and close keep the edits and send nothing; turning Required off then Save shows the clean row with Apply enabled.
|
||||||
|
15. The table of the feature (entries 12 and 0, optional and required, changed to required, mixed with a Capacity change); Apply stays disabled offline in both reviews.
|
||||||
|
16. A stale count: PUT 409 `schema_incompatible` runs the existing refusal flow.
|
||||||
|
|
||||||
|
MediaFieldControl (components/__tests__/MediaFieldControl.spec.ts), one test per state and viewport (components/__tests__/viewport.ts for 1440 and 390):
|
||||||
|
17. Empty optional and required: structure, copy, " *" hidden from AT, help line, 44 px button on mobile.
|
||||||
|
18. Filled: thumbnail 160 × 120 (full width 16:9 on mobile), filename, "1600 × 900 · 240 KB", buttons and icons, help line; vector image reads "Vector".
|
||||||
|
19. Hover bar on desktop only (`pointerenter` on the thumbnail), absent on mobile; focus ring classes on the keyboard-focused thumbnail.
|
||||||
|
20. Error: border classes, error line replaces help, aria-invalid and aria-describedby order (error id, help id), focus on "Pick an image" or "Choose another image".
|
||||||
|
21. Missing optional and required: placeholder role img and name, copy, no help line under the box, buttons.
|
||||||
|
22. Saving and read-only: form 70 %, thumbnail 60 %, buttons disabled, picker cannot open.
|
||||||
|
23. Events: emits `update:modelValue` with the id or null for pick, "Remove image" choice, Remove and Clear; announcements in one polite live region; focus targets after Use this image (Change image), Remove, Clear and "Remove image" (Pick an image), and after Cancel and Esc (the opener).
|
||||||
|
24. Names and role: group labelled by the label, the exact accessible names, aria-required on the main button, two tab stops as drawn.
|
||||||
|
|
||||||
|
Entry editor (views/__tests__/EntryEditorMedia*.spec.ts):
|
||||||
|
25. Position after Description, full width, never pairs; form built from the type with Thumbnail.
|
||||||
|
26. The table "What the control shows and sends" (it.each): loaded value and media map to shown state and PUT body.
|
||||||
|
27. After a pick the control shows the picked library item's data with no request; after the PUT it is rebuilt from the answered `media`; after "Reload form" from the refetched entry.
|
||||||
|
28. Dirty tracking: pick 8 then 7 is clean; pick 8 is dirty and the leave guard prompts.
|
||||||
|
29. Client validation: required empty and required missing give no request, "Not saved" chip, focus and scroll on the first error in schema order; picking clears the error; optional missing saves with the id unchanged.
|
||||||
|
30. Server answers: 422 required and type map to the control with their messages and focus; 422 `unknown_field` on a media key shows the "The Thumbnail field was changed..." line, other keys keep the state 12 copy of features/content_entry_editor.feature; create payload `thumbnail: null` and `thumbnail: 8`.
|
||||||
|
31. Title of an entry whose first field is media: "Entry N" in title, breadcrumb, toast and list card.
|
||||||
|
|
||||||
|
Picker field mode (components/__tests__/ImageUploaderField.spec.ts, views/__tests__/EntryEditorPicker.spec.ts):
|
||||||
|
32. Title, tabs, search, "No image" row on optional only and never on required, empty, error and loading states; "Current" badge replaces "In this article"; tile names with and without ", Current"; aria-pressed on tiles and on "No image"; no alt field, no variant select, and no `/v1/image-variants` request.
|
||||||
|
33. Selection: strip with 56 px thumbnail, filename and "dimensions · size"; Load more hidden while selected; "Use this image" `aria-disabled` until a choice; "Remove image" when No image is chosen; selecting a tile releases No image and the reverse; selecting the Current tile then Use closes with no change and no announcement.
|
||||||
|
34. Library states: empty (field copy with "or entry", and the article copy unchanged), 500 and network failure with Try again repeating only GET /v1/media/, 429 countdown alert, 401 global, loading skeleton after 150 ms (8 and 9 tiles), search trimmed and debounced, no results, Load more focus move.
|
||||||
|
35. Upload tab: unchanged flow including the duplicate card; a finished upload becomes the field value (per decision D5) and announces "Image selected: ..."; the four upload failure copies.
|
||||||
|
36. Dialog 640 px wide on desktop, bottom sheet on mobile; Cancel, Esc and close return focus to the opener.
|
||||||
|
37. Regression: the article picker and the Sites, Authors and SEO pickers keep their copy and behavior (run their existing specs unchanged; if one needs an edit, stop and report).
|
||||||
|
|
||||||
|
Entry list (components/__tests__/FieldCell.spec.ts, EntryTable, EntryCardList, ColumnPicker):
|
||||||
|
38. Thumbnail, "–" and missing cells: size 40 px, 4 px margins, row height 48 px, role img and name on the placeholder, tooltip on desktop only, `alt` of the thumbnail (proposed: the filename).
|
||||||
|
39. Header "Thumbnail": no sort icon, no `aria-sort`, 80 px; mobile card with a 32 px leading thumbnail and the 68 px minimum height, unchanged when no media column is chosen.
|
||||||
|
40. Columns menu: Thumbnail listed with the image icon and its key; "4 of 6" and the cap behaviour; stored choice in localStorage.
|
||||||
|
41. No extra request for thumbnails; a first media column still links to the editor.
|
||||||
|
|
||||||
|
Cross-cutting:
|
||||||
|
42. French: every new string has an entry (the i18n completeness spec passes, including the files added to the list in `i18n/contentMessages.spec.ts` if any).
|
||||||
|
43. Existing specs that assert six types or six tiles are updated to seven and nothing else in them changes.
|
||||||
|
|
||||||
|
## Visual (task visual:check)
|
||||||
|
|
||||||
|
Prerequisites in web/visual, no change under docs/design:
|
||||||
|
- `check.ts`: states of the board groups 5 and 6 that the design markdown does not list (`Thumbnail column`, `Columns menu with Thumbnail`, `Missing image: tooltip`; the ten `Media control: ...` states) must be added to the screens `content` and `entry`. Today `extraSources` entries whose group equals the screen's primary group are dropped (decision D3), so the runner must be changed to merge them. Groups 2 and 3 (screen `schema`) and group "10 Media picker for fields" (add to `extraSources.entry`, board RenewCMS Screens Schema.dc.html) are plain `extraSources` additions. Group 4 "Media" is a reference gallery with no SPA route: no setup, covered by Vitest. Group 1 "Custom type: read-only view" and group 11 are slice C.
|
||||||
|
- `states.ts`: Events gets an eighth field Thumbnail (media) in `schemaEventsFields` variants for these states only, so existing states keep their 7 fields. Image URLs (`thumbnail_url`, library `url`) are fulfilled by the setup with an SVG stand-in drawing the board's placeholder (repeating-linear-gradient 135deg between --ui-bg-elevated and --ui-bg-accented, mono label "1600 × 900"), as `stubBoardImage` does for the picker. The media size is 245760 bytes so `formatSize` prints "240 KB". Sidebar calls stay mocked. Every capture within 10 %, desktop and mobile, light and dark.
|
||||||
|
|
||||||
|
| Board group, state | Setup in web/visual/states.ts | Board sample data |
|
||||||
|
|---|---|---|
|
||||||
|
| 2, Media field selected | /schema/events, Events + saved optional Thumbnail, 37 entries; step: open the row "Thumbnail" | row "Thumbnail", key "thumbnail", "Field: Thumbnail", "Used in the API, so it can't change.", "Can't change after saving", tiles Text to Media, hint "Holds one image from the media library. ..." |
|
||||||
|
| 2, New media field, seven types | /schema/events (7 saved fields); step: Add field, label "Thumbnail", click tile Media | "New" badge, key "thumbnail" help "The name used in the API. ...", seven tiles, chip "Unsaved changes" |
|
||||||
|
| 2, Media field, required | /schema/events, saved Thumbnail required; step: open its row | marker "R" on the row, "Required" switch on with "An entry can't be saved with this field empty." |
|
||||||
|
| 3, Review: required media field refused | 12 entries (`total: 12`); step: add Thumbnail, Media, Required on, Save | "Review changes to Events", "Add 1 field", "Events has 12 entries. ...", "Add Thumbnail", "Can't be applied", "Media, required. A media field can't have a default, so the 12 existing entries would have no image. Make Thumbnail optional to add it.", "Back to editing keeps your other changes.", Apply disabled |
|
||||||
|
| 3, Review: optional media field | same with Required off | "Add Thumbnail", "Media, optional. 12 entries are unaffected.", Apply enabled |
|
||||||
|
| 5, Thumbnail column | /content/events; localStorage `renewcms-content-columns-events` = ["title","thumbnail","starts_on","category"]; the 7 standard rows 42, 41, 40, 39, 38, 37, 36 with thumbnail ok, ok, none, missing, ok, none, ok | header "Thumbnail", 40 px thumbnails, "–" on 40 and 37, dashed unlink square on 39; mobile 32 px leading thumbnail |
|
||||||
|
| 5, Columns menu with Thumbnail | same storage; step: click "Columns" | "Columns", "4 of 6", Thumbnail with the image icon, "Choose up to 6 fields to show. 4 selected." (mobile) |
|
||||||
|
| 5, Missing image: tooltip | step: hover the dashed square of row 39 (desktop); mobile has no tooltip and shows the list | "Image no longer in the library" |
|
||||||
|
| 6, Media control: empty, optional | /content/events/42 with thumbnail null; Events with Thumbnail | "Thumbnail", "No image selected.", "Pick an image", "One image from your library." |
|
||||||
|
| 6, Media control: empty, required | Thumbnail required, thumbnail null | "Thumbnail *", same body |
|
||||||
|
| 6, Media control: filled | thumbnail 7, media {id 7, "lime-plaster-workshop.jpg", 1600, 900, 245760} | "lime-plaster-workshop.jpg", "1600 × 900 · 240 KB", "Change image", "Remove", help |
|
||||||
|
| 6, Media control: hover | filled; step: hover the thumbnail (desktop); mobile is the filled frame | bar "Change image" |
|
||||||
|
| 6, Media control: focus | filled; step: Tab to the thumbnail (keyboard, so :focus-visible) | 2 px primary ring, name "Change image, lime-plaster-workshop.jpg" |
|
||||||
|
| 6, Media control: validation error | Thumbnail required, thumbnail null; step: click Save | "Pick an image for Thumbnail.", chip "Not saved", focus on "Pick an image" |
|
||||||
|
| 6, Media control: image no longer in the library | optional, media {id 7, missing true} | "This image is no longer in the library", "You can still save. The entry keeps the link until you change it.", "Choose another image", "Clear" |
|
||||||
|
| 6, Media control: missing image, required | required, missing; step: click Save | "Thumbnail is required. Choose another image to save.", "Pick an image for Thumbnail.", "Not saved", focus on "Choose another image" |
|
||||||
|
| 6, Media control: saving | filled; step: edit Title, Save, PUT `hang: true` | "Saving…", thumbnail at 60 % |
|
||||||
|
| 6, Media control: read-only, type changed | filled; step: edit Capacity, Save, PUT 422 `invalid_entry: thumbnail: unknown_field` | "The fields of Events changed", "The Thumbnail field was changed after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.", "Reload form" |
|
||||||
|
| 10, Library: nothing chosen | /content/events/42 (control holds image 1 "north-wall.jpg", optional); step: click "Change image"; library = the board's 12 images, counts.all 48 | "Choose an image", "Upload", "Library", "Search by filename", "No image", "Leave this field empty.", "Current" on north-wall.jpg, "Newest first · 48 images", "Load more", "Cancel", "Use this image" (disabled) |
|
||||||
|
| 10, Library: image chosen | step: select the 4th tile (the frame draws id 4 logo-renew.svg, "Vector · 8 KB"; the copy list says kitchen-before.webp, report the mismatch) | strip with filename, "dimensions · size", "Use this image" enabled |
|
||||||
|
| 10, Library: No image chosen | step: click the row "No image" | sand row with check, "Remove image" |
|
||||||
|
| 10, Library: required field | Thumbnail required | no "No image" row, "Use this image" disabled |
|
||||||
|
| 10, Library: empty | `GET /v1/media/` returns no item | "Your library is empty", "Images you upload appear here, ready to use in any article or entry.", "Upload an image" |
|
||||||
|
| 10, Library: failed to load | `GET /v1/media/` 500 | "Couldn't load your library", "Check your connection and try again.", "Try again" |
|
||||||
|
| 10, Library: loading | `GET /v1/media/` `hang: true`; wait for the skeleton tile | skeleton tiles, "Use this image" disabled |
|
||||||
|
| 10, Upload tab | step: click the tab "Upload" | "Drop an image here, or choose a file", "PNG, JPEG, WEBP, SVG · max 10 MB" |
|
||||||
|
|
||||||
|
Board caveats to report, never hide:
|
||||||
|
- The group 6 media states draw the form with a negative margin (-380 px desktop, -560 px mobile) under an intact title row inside an overflow-hidden box. A real page can only scroll as a whole, so the title row and topbar will differ. If a capture exceeds 10 %, an exception needs the user's explicit approval (`approvedBy`), as for existing schema states. Do not call `schemaSettle` after scrolling to the control, since it resets scrollTop.
|
||||||
|
- Group 10 draws a stand-in entry form behind the dialog (design conflict 13), not the real editor. Expect a possible exception request for the page behind the dialog.
|
||||||
|
- The frame and the copy list disagree on "Library: image chosen" (see above) and on the list row titles of "Thumbnail column"; the frame wins.
|
||||||
|
- Group 6 "Saved" is not checked here; the existing entry setups are untouched.
|
||||||
|
|
||||||
|
## Manual
|
||||||
|
|
||||||
|
- Real backend, owner and a second verified user: create a type with a required Title and an optional Thumbnail; add an entry with an image; the thumbnail shows in the editor and in the list; change the image; remove it; save.
|
||||||
|
- Delete the image from the Media page, reload the entry: the missing state, then save with the optional field kept (the id is sent unchanged); make the field required through a type with no entries and see "Pick an image for Thumbnail." on save.
|
||||||
|
- Add a required Thumbnail to a type with entries: the review refuses with Apply disabled; make it optional and apply (real PUT 200). Make an existing optional media field required while an entry has none: real 409, then the existing refusal flow.
|
||||||
|
- Upload from the Upload tab of the picker with crop and resize, and an already-in-library duplicate; confirm the final selection behaves as decided in D5.
|
||||||
|
- Keyboard: tab order across the control (thumbnail, Change image, Remove), Enter and Space on a tile, Esc and Cancel return focus to the opener, focus after Use this image, Remove and Clear; screen reader announcements of the live region and of the error; contrast of the Current badge and the hover bar in light and dark.
|
||||||
|
- Mobile: bottom sheet, 44 px targets, no hover.
|
||||||
@@ -132,6 +132,7 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
|
|||||||
:type-key="typeKey"
|
:type-key="typeKey"
|
||||||
:entries="list.items.value"
|
:entries="list.items.value"
|
||||||
:fields="fields"
|
:fields="fields"
|
||||||
|
:columns="columns.columns.value"
|
||||||
:names="list.names.value"
|
:names="list.names.value"
|
||||||
:caption="caption"
|
:caption="caption"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const props = withDefaults(
|
|||||||
notice?: DirectoryNotice | null
|
notice?: DirectoryNotice | null
|
||||||
closable?: boolean
|
closable?: boolean
|
||||||
confirmDisabled?: boolean
|
confirmDisabled?: boolean
|
||||||
|
confirmBlocked?: boolean
|
||||||
wide?: boolean
|
wide?: boolean
|
||||||
closeGutter?: boolean
|
closeGutter?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
@@ -27,6 +28,7 @@ const props = withDefaults(
|
|||||||
notice: null,
|
notice: null,
|
||||||
closable: false,
|
closable: false,
|
||||||
confirmDisabled: false,
|
confirmDisabled: false,
|
||||||
|
confirmBlocked: false,
|
||||||
wide: false,
|
wide: false,
|
||||||
closeGutter: false,
|
closeGutter: false,
|
||||||
},
|
},
|
||||||
@@ -78,6 +80,12 @@ function onOpenChange(value: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onConfirm() {
|
||||||
|
if (!props.confirmBlocked) {
|
||||||
|
emit('confirm')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onCancel() {
|
function onCancel() {
|
||||||
emit('cancel')
|
emit('cancel')
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
@@ -146,10 +154,14 @@ function onCancel() {
|
|||||||
:color="tone === 'danger' ? 'error' : 'primary'"
|
:color="tone === 'danger' ? 'error' : 'primary'"
|
||||||
:loading="pending"
|
:loading="pending"
|
||||||
:disabled="pending || confirmDisabled"
|
:disabled="pending || confirmDisabled"
|
||||||
|
:aria-disabled="confirmBlocked ? 'true' : undefined"
|
||||||
block
|
block
|
||||||
class="h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3"
|
class="h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3"
|
||||||
:class="wide ? 'disabled:opacity-40' : ''"
|
:class="[
|
||||||
@click="emit('confirm')"
|
wide ? 'disabled:opacity-40' : '',
|
||||||
|
confirmBlocked ? 'cursor-not-allowed opacity-40' : '',
|
||||||
|
]"
|
||||||
|
@click="onConfirm"
|
||||||
>
|
>
|
||||||
{{ confirmText }}
|
{{ confirmText }}
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|||||||
@@ -9,8 +9,19 @@ withDefaults(
|
|||||||
error?: string
|
error?: string
|
||||||
help?: string
|
help?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
labelId?: string
|
||||||
|
errorId?: string
|
||||||
}>(),
|
}>(),
|
||||||
{ label: '', optional: false, required: false, error: '', help: '', name: undefined },
|
{
|
||||||
|
label: '',
|
||||||
|
optional: false,
|
||||||
|
required: false,
|
||||||
|
error: '',
|
||||||
|
help: '',
|
||||||
|
name: undefined,
|
||||||
|
labelId: undefined,
|
||||||
|
errorId: undefined,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -30,7 +41,7 @@ withDefaults(
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template v-if="label" #label>
|
<template v-if="label" #label>
|
||||||
{{ label }}<span v-if="required" aria-hidden="true"> *</span>
|
<span :id="labelId">{{ label }}<span v-if="required" aria-hidden="true"> *</span></span>
|
||||||
<span v-if="optional" class="font-normal text-muted"> {{ t('(optional)') }}</span>
|
<span v-if="optional" class="font-normal text-muted"> {{ t('(optional)') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="$slots.hint" #hint>
|
<template v-if="$slots.hint" #hint>
|
||||||
@@ -38,7 +49,7 @@ withDefaults(
|
|||||||
</template>
|
</template>
|
||||||
<slot />
|
<slot />
|
||||||
<template v-if="error" #error>
|
<template v-if="error" #error>
|
||||||
<span class="flex items-start gap-1.5" role="alert">
|
<span :id="errorId" class="flex items-start gap-1.5" role="alert">
|
||||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||||
<span>{{ error }}</span>
|
<span>{{ error }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,7 +2,16 @@
|
|||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import FieldCell from '@/components/FieldCell.vue'
|
import FieldCell from '@/components/FieldCell.vue'
|
||||||
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
||||||
import { EMPTY_TEXT, cardFields, describeCell, isEmptyCell, plainCellText } from '@/lib/entryCells'
|
import {
|
||||||
|
EMPTY_TEXT,
|
||||||
|
cardFields,
|
||||||
|
describeCell,
|
||||||
|
isEmptyCell,
|
||||||
|
plainCellText,
|
||||||
|
thumbnailColumn,
|
||||||
|
} from '@/lib/entryCells'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { mediaOf } from '@/lib/mediaField'
|
||||||
import { entryPath } from '@/lib/routePaths'
|
import { entryPath } from '@/lib/routePaths'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
@@ -10,14 +19,28 @@ const props = defineProps<{
|
|||||||
typeKey: string
|
typeKey: string
|
||||||
entries: ContentEntry[]
|
entries: ContentEntry[]
|
||||||
fields: BuiltinField[]
|
fields: BuiltinField[]
|
||||||
|
columns: BuiltinField[]
|
||||||
names: Record<number, string>
|
names: Record<number, string>
|
||||||
caption: string
|
caption: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const shown = computed(() => cardFields(props.fields))
|
const shown = computed(() => cardFields(props.fields))
|
||||||
|
const lead = computed(() => thumbnailColumn(props.columns))
|
||||||
|
|
||||||
function textOf(field: BuiltinField | undefined, entry: ContentEntry) {
|
function textOf(field: BuiltinField | undefined, entry: ContentEntry) {
|
||||||
return field ? plainCellText(describeCell(field, entry.data[field.key])) : ''
|
return field
|
||||||
|
? plainCellText(
|
||||||
|
describeCell(field, entry.data[field.key], mediaOf(entry, field.key) ?? undefined),
|
||||||
|
)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleOf(entry: ContentEntry) {
|
||||||
|
const { first } = shown.value
|
||||||
|
if (first && describeCell(first, entry.data[first.key]).kind === 'thumbnail') {
|
||||||
|
return t('Entry {0}', [entry.id])
|
||||||
|
}
|
||||||
|
return textOf(first, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
const secondIsChoice = computed(
|
const secondIsChoice = computed(
|
||||||
@@ -46,9 +69,16 @@ function nameOf(entry: ContentEntry) {
|
|||||||
data-testid="entry-card"
|
data-testid="entry-card"
|
||||||
class="flex min-h-17 items-center gap-2 py-3 pr-1 pl-4 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
|
class="flex min-h-17 items-center gap-2 py-3 pr-1 pl-4 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
|
||||||
>
|
>
|
||||||
|
<FieldCell
|
||||||
|
v-if="lead"
|
||||||
|
:field="lead"
|
||||||
|
:value="entry.data[lead.key]"
|
||||||
|
:image="mediaOf(entry, lead.key)"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
<span class="flex min-w-0 flex-1 flex-col gap-1">
|
<span class="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
<span class="truncate text-md/5.25 font-medium text-highlighted">{{
|
<span class="truncate text-md/5.25 font-medium text-highlighted">{{
|
||||||
textOf(shown.first, entry)
|
titleOf(entry)
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="flex items-center gap-2 text-xs text-muted">
|
<span class="flex items-center gap-2 text-xs text-muted">
|
||||||
<template v-if="shown.second && !secondIsChoice">
|
<template v-if="shown.second && !secondIsChoice">
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ function backToList() {
|
|||||||
<EntryForm
|
<EntryForm
|
||||||
:fields="page.fields.value"
|
:fields="page.fields.value"
|
||||||
:values="page.form.values.value"
|
:values="page.form.values.value"
|
||||||
|
:images="page.form.images.value"
|
||||||
:issues="page.form.issues.value"
|
:issues="page.form.issues.value"
|
||||||
:first-issue="page.form.firstIssue.value"
|
:first-issue="page.form.firstIssue.value"
|
||||||
:focus-token="page.focusToken.value"
|
:focus-token="page.focusToken.value"
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { computed } from 'vue'
|
|||||||
import BuilderBadge from '@/components/BuilderBadge.vue'
|
import BuilderBadge from '@/components/BuilderBadge.vue'
|
||||||
import DirectoryField from '@/components/DirectoryField.vue'
|
import DirectoryField from '@/components/DirectoryField.vue'
|
||||||
import EntryControl from '@/components/EntryControl.vue'
|
import EntryControl from '@/components/EntryControl.vue'
|
||||||
|
import MediaFieldControl from '@/components/MediaFieldControl.vue'
|
||||||
import type { BuiltinField } from '@/lib/contentTypes'
|
import type { BuiltinField } from '@/lib/contentTypes'
|
||||||
import type { FieldIssue } from '@/lib/entryForm'
|
import type { FieldIssue } from '@/lib/entryForm'
|
||||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||||
|
import { isMediaId, type MediaSummary } from '@/lib/mediaField'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
field: BuiltinField
|
field: BuiltinField
|
||||||
@@ -13,18 +15,33 @@ const props = defineProps<{
|
|||||||
issue?: FieldIssue
|
issue?: FieldIssue
|
||||||
isDefault: boolean
|
isDefault: boolean
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
|
image?: MediaSummary | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{ update: [key: string, value: unknown] }>()
|
const emit = defineEmits<{ update: [key: string, value: unknown, image?: MediaSummary | null] }>()
|
||||||
|
|
||||||
const editor = computed(() => fieldTypeSpec(props.field.type).editor)
|
const editor = computed(() => fieldTypeSpec(props.field.type).editor)
|
||||||
const counter = computed(() => editor.value.counter(props.field, props.value))
|
const counter = computed(() => editor.value.counter(props.field, props.value))
|
||||||
const help = computed(() => editor.value.help(props.field))
|
const help = computed(() => editor.value.help(props.field))
|
||||||
|
const isMedia = computed(() => editor.value.control === 'media')
|
||||||
|
const mediaId = computed(() => (isMediaId(props.value) ? props.value : null))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :data-entry-field="field.key" class="min-w-0">
|
<div :data-entry-field="field.key" class="min-w-0">
|
||||||
|
<MediaFieldControl
|
||||||
|
v-if="isMedia"
|
||||||
|
:label="field.label"
|
||||||
|
:name="field.key"
|
||||||
|
:required="field.required"
|
||||||
|
:model-value="mediaId"
|
||||||
|
:image="image"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="issue?.message"
|
||||||
|
@change="(id, picked) => emit('update', field.key, id, picked)"
|
||||||
|
/>
|
||||||
<DirectoryField
|
<DirectoryField
|
||||||
|
v-else
|
||||||
:label="field.label"
|
:label="field.label"
|
||||||
:name="field.key"
|
:name="field.key"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ import { computed, nextTick, watch } from 'vue'
|
|||||||
import EntryField from '@/components/EntryField.vue'
|
import EntryField from '@/components/EntryField.vue'
|
||||||
import type { BuiltinField } from '@/lib/contentTypes'
|
import type { BuiltinField } from '@/lib/contentTypes'
|
||||||
import type { EntryIssues, EntryValues } from '@/lib/entryForm'
|
import type { EntryIssues, EntryValues } from '@/lib/entryForm'
|
||||||
|
import type { MediaMap, MediaSummary } from '@/lib/mediaField'
|
||||||
import { groupFields } from '@/lib/entryLayout'
|
import { groupFields } from '@/lib/entryLayout'
|
||||||
|
|
||||||
const FOCUSABLE =
|
const FOCUSABLE =
|
||||||
'input:not([type="hidden"]), textarea, [role="spinbutton"], [role="switch"], [role="combobox"]'
|
'[data-field-focus], input:not([type="hidden"]), textarea, [role="spinbutton"], [role="switch"], [role="combobox"]'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
fields: BuiltinField[]
|
fields: BuiltinField[]
|
||||||
values: EntryValues
|
values: EntryValues
|
||||||
|
images: MediaMap
|
||||||
issues: EntryIssues
|
issues: EntryIssues
|
||||||
firstIssue: string | null
|
firstIssue: string | null
|
||||||
focusToken: number
|
focusToken: number
|
||||||
@@ -25,7 +27,10 @@ const props = defineProps<{
|
|||||||
updatedLine: string
|
updatedLine: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{ update: [key: string, value: unknown]; submit: [] }>()
|
const emit = defineEmits<{
|
||||||
|
update: [key: string, value: unknown, image?: MediaSummary | null]
|
||||||
|
submit: []
|
||||||
|
}>()
|
||||||
|
|
||||||
const rows = computed(() => groupFields(props.fields))
|
const rows = computed(() => groupFields(props.fields))
|
||||||
|
|
||||||
@@ -67,10 +72,11 @@ watch(() => props.focusToken, focusFirstIssue)
|
|||||||
:key="field.key"
|
:key="field.key"
|
||||||
:field="field"
|
:field="field"
|
||||||
:value="values[field.key]"
|
:value="values[field.key]"
|
||||||
|
:image="images[field.key] ?? null"
|
||||||
:issue="issues[field.key]"
|
:issue="issues[field.key]"
|
||||||
:is-default="isDefault(field)"
|
:is-default="isDefault(field)"
|
||||||
:disabled="locked"
|
:disabled="locked"
|
||||||
@update="(key, value) => emit('update', key, value)"
|
@update="(key, value, image) => emit('update', key, value, image)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import RetryAlert from '@/components/RetryAlert.vue'
|
|||||||
import type { SaveFailure } from '@/composables/useEntryDraft'
|
import type { SaveFailure } from '@/composables/useEntryDraft'
|
||||||
import type { BuiltinField } from '@/lib/contentTypes'
|
import type { BuiltinField } from '@/lib/contentTypes'
|
||||||
import type { EntryIssues } from '@/lib/entryForm'
|
import type { EntryIssues } from '@/lib/entryForm'
|
||||||
import { schemaChangedLine, uniqueAlert } from '@/lib/entryMessages'
|
import { mediaChangedLine, schemaChangedLine, uniqueAlert } from '@/lib/entryMessages'
|
||||||
|
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
failure: SaveFailure
|
failure: SaveFailure
|
||||||
@@ -22,6 +23,12 @@ defineEmits<{ retry: []; reload: [] }>()
|
|||||||
const uniqueField = computed(() =>
|
const uniqueField = computed(() =>
|
||||||
props.fields.find((field) => props.issues[field.key]?.rule === 'unique'),
|
props.fields.find((field) => props.issues[field.key]?.rule === 'unique'),
|
||||||
)
|
)
|
||||||
|
const changedLine = computed(() => {
|
||||||
|
const changed = props.fields.find((field) => field.key === props.schemaField)
|
||||||
|
return changed && fieldTypeSpec(changed.type).editor.control === 'media'
|
||||||
|
? mediaChangedLine(changed.label)
|
||||||
|
: schemaChangedLine(props.schemaField)
|
||||||
|
})
|
||||||
const alertUi = {
|
const alertUi = {
|
||||||
root: 'items-start gap-2.5 px-3.5 py-3',
|
root: 'items-start gap-2.5 px-3.5 py-3',
|
||||||
title: 'font-semibold',
|
title: 'font-semibold',
|
||||||
@@ -60,7 +67,7 @@ const alertUi = {
|
|||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
icon="i-lucide-triangle-alert"
|
icon="i-lucide-triangle-alert"
|
||||||
:title="t('The fields of {0} changed', [typeName])"
|
:title="t('The fields of {0} changed', [typeName])"
|
||||||
:description="schemaChangedLine(schemaField)"
|
:description="changedLine"
|
||||||
:ui="{ ...alertUi, actions: 'mt-1.5' }"
|
:ui="{ ...alertUi, actions: 'mt-1.5' }"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
|||||||
import { EMPTY_TEXT, formatEntryDate } from '@/lib/entryCells'
|
import { EMPTY_TEXT, formatEntryDate } from '@/lib/entryCells'
|
||||||
import { ENTRY_SORT_FIELDS, sortParts } from '@/lib/entryListQuery'
|
import { ENTRY_SORT_FIELDS, sortParts } from '@/lib/entryListQuery'
|
||||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||||
|
import { mediaOf } from '@/lib/mediaField'
|
||||||
import { entryPath } from '@/lib/routePaths'
|
import { entryPath } from '@/lib/routePaths'
|
||||||
|
|
||||||
const ID_WIDTH = 'w-16.5'
|
const ID_WIDTH = 'w-16.5'
|
||||||
@@ -150,10 +151,16 @@ watchPostEffect(() => {
|
|||||||
<FieldCell
|
<FieldCell
|
||||||
:field="field"
|
:field="field"
|
||||||
:value="row.original.data[field.key]"
|
:value="row.original.data[field.key]"
|
||||||
|
:image="mediaOf(row.original, field.key)"
|
||||||
class="min-w-0"
|
class="min-w-0"
|
||||||
/>
|
/>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<FieldCell v-else :field="field" :value="row.original.data[field.key]" />
|
<FieldCell
|
||||||
|
v-else
|
||||||
|
:field="field"
|
||||||
|
:value="row.original.data[field.key]"
|
||||||
|
:image="mediaOf(row.original, field.key)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #created_at-cell="{ row }">
|
<template #created_at-cell="{ row }">
|
||||||
|
|||||||
@@ -4,14 +4,63 @@ import { t } from '@/i18n'
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { BuiltinField } from '@/lib/contentTypes'
|
import type { BuiltinField } from '@/lib/contentTypes'
|
||||||
import { EMPTY_TEXT, describeCell, isEmptyCell } from '@/lib/entryCells'
|
import { EMPTY_TEXT, describeCell, isEmptyCell } from '@/lib/entryCells'
|
||||||
|
import { isMissingImage, type MediaSummary } from '@/lib/mediaField'
|
||||||
|
|
||||||
const props = defineProps<{ field: BuiltinField; value: unknown }>()
|
const props = defineProps<{
|
||||||
|
field: BuiltinField
|
||||||
|
value: unknown
|
||||||
|
image?: MediaSummary | null
|
||||||
|
compact?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
const cell = computed(() => describeCell(props.field, props.value))
|
const cell = computed(() => describeCell(props.field, props.value, props.image ?? undefined))
|
||||||
|
const shownImage = computed(() => {
|
||||||
|
const image = cell.value.image
|
||||||
|
return image && !isMissingImage(image) ? image : null
|
||||||
|
})
|
||||||
|
const squareClass = computed(() => (props.compact ? 'size-8' : 'size-10'))
|
||||||
|
const TIP_UI = {
|
||||||
|
content:
|
||||||
|
'h-auto items-center bg-inverted px-2 py-1.5 text-xs/4.5 text-inverted shadow-(--shadow-popover) ring-0',
|
||||||
|
text: 'whitespace-nowrap',
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span v-if="isEmptyCell(cell)" class="text-muted">{{ EMPTY_TEXT }}</span>
|
<span
|
||||||
|
v-if="isEmptyCell(cell)"
|
||||||
|
class="text-muted"
|
||||||
|
:class="field.type === 'media' && compact ? 'inline-block w-8 text-center' : ''"
|
||||||
|
>{{ EMPTY_TEXT }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-else-if="cell.kind === 'thumbnail' && shownImage"
|
||||||
|
class="block shrink-0 overflow-hidden bg-elevated"
|
||||||
|
:class="squareClass"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="shownImage.thumbnail_url"
|
||||||
|
:alt="shownImage.filename"
|
||||||
|
loading="lazy"
|
||||||
|
class="size-full object-cover"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<UTooltip
|
||||||
|
v-else-if="cell.kind === 'thumbnail'"
|
||||||
|
:text="t('Image no longer in the library')"
|
||||||
|
:disabled="compact"
|
||||||
|
:ui="TIP_UI"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
:aria-label="t('Image no longer in the library')"
|
||||||
|
:tabindex="compact ? undefined : 0"
|
||||||
|
class="flex shrink-0 items-center justify-center border border-dashed border-accented text-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||||
|
:class="squareClass"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-unlink" :class="compact ? 'size-3' : 'size-3.5'" />
|
||||||
|
</span>
|
||||||
|
</UTooltip>
|
||||||
<span v-else-if="cell.kind === 'boolean'" class="flex items-center gap-1.5">
|
<span v-else-if="cell.kind === 'boolean'" class="flex items-center gap-1.5">
|
||||||
<UIcon
|
<UIcon
|
||||||
:name="cell.yes ? 'i-lucide-check' : 'i-lucide-minus'"
|
:name="cell.yes ? 'i-lucide-check' : 'i-lucide-minus'"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ChoiceList from '@/components/ChoiceList.vue'
|
|||||||
import DirectoryField from '@/components/DirectoryField.vue'
|
import DirectoryField from '@/components/DirectoryField.vue'
|
||||||
import FieldTypePicker from '@/components/FieldTypePicker.vue'
|
import FieldTypePicker from '@/components/FieldTypePicker.vue'
|
||||||
import SlugField from '@/components/SlugField.vue'
|
import SlugField from '@/components/SlugField.vue'
|
||||||
|
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||||
import {
|
import {
|
||||||
BUILDER_LIMITS,
|
BUILDER_LIMITS,
|
||||||
STARTER_KEY,
|
STARTER_KEY,
|
||||||
@@ -50,8 +51,14 @@ const emit = defineEmits<{
|
|||||||
const root = ref<HTMLElement | null>(null)
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const type = computed(() => props.field.type)
|
const type = computed(() => props.field.type)
|
||||||
|
const spec = computed(() => fieldTypeSpec(type.value))
|
||||||
const hasLimits = computed(() => supportsLimits(type.value))
|
const hasLimits = computed(() => supportsLimits(type.value))
|
||||||
const canUnique = computed(() => supportsUnique(type.value))
|
const canUnique = computed(() => supportsUnique(type.value))
|
||||||
|
const showUnique = computed(() => canUnique.value || spec.value.showsUnsupportedOptions)
|
||||||
|
const showRuleGrid = computed(
|
||||||
|
() => hasLimits.value || spec.value.supportsDefault || spec.value.showsUnsupportedOptions,
|
||||||
|
)
|
||||||
|
const hint = computed(() => spec.value.builderHint?.() ?? '')
|
||||||
const keyMode = computed(() => keyDisplayMode(props.field.keyMode, props.field.key))
|
const keyMode = computed(() => keyDisplayMode(props.field.keyMode, props.field.key))
|
||||||
const gridClass = computed(() => (props.compact ? 'grid-cols-1' : 'md:grid-cols-2'))
|
const gridClass = computed(() => (props.compact ? 'grid-cols-1' : 'md:grid-cols-2'))
|
||||||
|
|
||||||
@@ -196,6 +203,7 @@ watch(
|
|||||||
@update:model-value="emit('required', Boolean($event))"
|
@update:model-value="emit('required', Boolean($event))"
|
||||||
/>
|
/>
|
||||||
<AppSwitch
|
<AppSwitch
|
||||||
|
v-if="showUnique"
|
||||||
:model-value="canUnique && field.unique"
|
:model-value="canUnique && field.unique"
|
||||||
:label="t('Unique values')"
|
:label="t('Unique values')"
|
||||||
:description="uniqueHelp"
|
:description="uniqueHelp"
|
||||||
@@ -214,7 +222,7 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid items-start gap-3.5" :class="gridClass">
|
<div v-if="showRuleGrid" class="grid items-start gap-3.5" :class="gridClass">
|
||||||
<template v-if="hasLimits">
|
<template v-if="hasLimits">
|
||||||
<DirectoryField :label="limitLabels[0]" name="min" :error="errors.min">
|
<DirectoryField :label="limitLabels[0]" name="min" :error="errors.min">
|
||||||
<UInput
|
<UInput
|
||||||
@@ -255,7 +263,7 @@ watch(
|
|||||||
</DirectoryField>
|
</DirectoryField>
|
||||||
</template>
|
</template>
|
||||||
<DirectoryField
|
<DirectoryField
|
||||||
v-if="type !== 'boolean'"
|
v-if="type !== 'boolean' && spec.supportsDefault"
|
||||||
:label="t('Default value')"
|
:label="t('Default value')"
|
||||||
name="default"
|
name="default"
|
||||||
:error="errors.default"
|
:error="errors.default"
|
||||||
@@ -292,14 +300,26 @@ watch(
|
|||||||
@update:model-value="emit('default', String($event))"
|
@update:model-value="emit('default', String($event))"
|
||||||
/>
|
/>
|
||||||
</DirectoryField>
|
</DirectoryField>
|
||||||
<div v-if="!hasLimits" class="flex min-w-0 flex-col gap-1.5">
|
<div
|
||||||
|
v-if="!hasLimits && spec.showsUnsupportedOptions"
|
||||||
|
class="flex min-w-0 flex-col gap-1.5"
|
||||||
|
>
|
||||||
<span class="font-medium text-highlighted">{{ t('Limits') }}</span>
|
<span class="font-medium text-highlighted">{{ t('Limits') }}</span>
|
||||||
<span class="text-xs/4.5 text-muted">{{ noLimitsText }}</span>
|
<span class="text-xs/4.5 text-muted">{{ noLimitsText }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="hint"
|
||||||
|
data-testid="builder-hint"
|
||||||
|
class="flex gap-2 bg-(--rc-info-soft) px-3 py-2.5 text-info"
|
||||||
|
>
|
||||||
|
<span class="flex pt-0.5"><UIcon name="i-lucide-info" class="size-4" /></span>
|
||||||
|
<span class="text-pretty text-toned">{{ hint }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ChoiceList
|
<ChoiceList
|
||||||
v-if="type === 'select'"
|
v-if="spec.supportsChoices"
|
||||||
:options="field.options"
|
:options="field.options"
|
||||||
:row-errors="errors.choices"
|
:row-errors="errors.choices"
|
||||||
:list-error="errors.options"
|
:list-error="errors.options"
|
||||||
|
|||||||
@@ -49,21 +49,24 @@ import {
|
|||||||
|
|
||||||
type Corner = 'tl' | 'tr' | 'bl' | 'br'
|
type Corner = 'tl' | 'tr' | 'bl' | 'br'
|
||||||
import { useImageUpload } from '@/composables/useImageUpload'
|
import { useImageUpload } from '@/composables/useImageUpload'
|
||||||
import type { ImageChoice } from '@/lib/imageChoice'
|
import type { FieldPick, ImageChoice } from '@/lib/imageChoice'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
initialFile?: File | null
|
initialFile?: File | null
|
||||||
inArticleIds?: number[]
|
inArticleIds?: number[]
|
||||||
choose?: ImageChoice
|
choose?: ImageChoice
|
||||||
|
field?: FieldPick
|
||||||
}>(),
|
}>(),
|
||||||
{ initialFile: null, inArticleIds: () => [], choose: undefined },
|
{ initialFile: null, inArticleIds: () => [], choose: undefined, field: undefined },
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
uploaded: [item: MediaItem]
|
uploaded: [item: MediaItem]
|
||||||
inserted: [item: MediaItem, alt: string, src: string]
|
inserted: [item: MediaItem, alt: string, src: string]
|
||||||
|
chosen: [item: MediaItem | null]
|
||||||
close: []
|
close: []
|
||||||
|
closed: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
type Phase = 'idle' | 'dragging-over' | 'duplicate' | 'crop' | 'uploading' | 'rejected' | 'failed'
|
type Phase = 'idle' | 'dragging-over' | 'duplicate' | 'crop' | 'uploading' | 'rejected' | 'failed'
|
||||||
@@ -78,6 +81,9 @@ const DROP_ZONE_UI = {
|
|||||||
description: 'mt-0 max-w-75 text-xs/5 text-toned',
|
description: 'mt-0 max-w-75 text-xs/5 text-toned',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HEADER_CLASS = 'min-h-12 items-start px-6 pt-5 pb-0 sm:px-6'
|
||||||
|
const FIELD_HEADER_CLASS = `${HEADER_CLASS} max-md:min-h-16`
|
||||||
|
const FIELD_CLOSE = { class: 'size-7 justify-center text-muted max-md:size-11' }
|
||||||
const FOOTER_BUTTON_CLASS = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
|
const FOOTER_BUTTON_CLASS = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
|
||||||
|
|
||||||
const TAB_ITEMS = [
|
const TAB_ITEMS = [
|
||||||
@@ -108,6 +114,7 @@ const succeeded = ref(false)
|
|||||||
const tab = ref<Tab>('library')
|
const tab = ref<Tab>('library')
|
||||||
const existing = ref<MediaItem | null>(null)
|
const existing = ref<MediaItem | null>(null)
|
||||||
const selected = ref<MediaItem | null>(null)
|
const selected = ref<MediaItem | null>(null)
|
||||||
|
const noneChosen = ref(false)
|
||||||
const alt = ref('')
|
const alt = ref('')
|
||||||
const variant = ref('')
|
const variant = ref('')
|
||||||
let tabTouched = false
|
let tabTouched = false
|
||||||
@@ -192,6 +199,9 @@ const modalTitle = computed(() => {
|
|||||||
if (phase.value === 'crop') {
|
if (phase.value === 'crop') {
|
||||||
return t('Crop and resize')
|
return t('Crop and resize')
|
||||||
}
|
}
|
||||||
|
if (props.field) {
|
||||||
|
return t('Choose an image')
|
||||||
|
}
|
||||||
return props.choose?.title ?? t('Add an image')
|
return props.choose?.title ?? t('Add an image')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -432,6 +442,7 @@ function useExisting() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectItem(item: MediaItem) {
|
function selectItem(item: MediaItem) {
|
||||||
|
noneChosen.value = false
|
||||||
if (selected.value?.id === item.id) {
|
if (selected.value?.id === item.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -441,8 +452,28 @@ function selectItem(item: MediaItem) {
|
|||||||
requestVariants()
|
requestVariants()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectNone() {
|
||||||
|
selected.value = null
|
||||||
|
noneChosen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const canConfirm = computed(() => selected.value !== null || noneChosen.value)
|
||||||
|
|
||||||
|
function confirmField() {
|
||||||
|
if (!canConfirm.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('chosen', noneChosen.value ? null : selected.value)
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function returnFocus(event: Event) {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('closed')
|
||||||
|
}
|
||||||
|
|
||||||
function requestVariants() {
|
function requestVariants() {
|
||||||
if (variantsRequested || !canManageVariants.value) {
|
if (props.field || variantsRequested || !canManageVariants.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
variantsRequested = true
|
variantsRequested = true
|
||||||
@@ -451,6 +482,7 @@ function requestVariants() {
|
|||||||
|
|
||||||
async function searchLibrary(query: string) {
|
async function searchLibrary(query: string) {
|
||||||
selected.value = null
|
selected.value = null
|
||||||
|
noneChosen.value = false
|
||||||
alt.value = ''
|
alt.value = ''
|
||||||
variant.value = ''
|
variant.value = ''
|
||||||
await library.search(query)
|
await library.search(query)
|
||||||
@@ -739,6 +771,7 @@ watch(open, (isOpen) => {
|
|||||||
succeeded.value = false
|
succeeded.value = false
|
||||||
existing.value = null
|
existing.value = null
|
||||||
selected.value = null
|
selected.value = null
|
||||||
|
noneChosen.value = false
|
||||||
alt.value = ''
|
alt.value = ''
|
||||||
variant.value = ''
|
variant.value = ''
|
||||||
variantsRequested = false
|
variantsRequested = false
|
||||||
@@ -771,9 +804,11 @@ onUnmounted(() => {
|
|||||||
v-model:open="open"
|
v-model:open="open"
|
||||||
:title="modalTitle"
|
:title="modalTitle"
|
||||||
:description="modalDescription"
|
:description="modalDescription"
|
||||||
|
:content="field ? { onCloseAutoFocus: returnFocus } : undefined"
|
||||||
|
:close="field ? FIELD_CLOSE : undefined"
|
||||||
:ui="{
|
:ui="{
|
||||||
content: modalContentClass,
|
content: modalContentClass,
|
||||||
header: 'min-h-12 items-start px-6 pt-5 pb-0 sm:px-6',
|
header: field ? FIELD_HEADER_CLASS : HEADER_CLASS,
|
||||||
close: 'top-5 end-6',
|
close: 'top-5 end-6',
|
||||||
title: 'text-lg/6 font-semibold',
|
title: 'text-lg/6 font-semibold',
|
||||||
body: 'p-0 sm:p-0',
|
body: 'p-0 sm:p-0',
|
||||||
@@ -813,11 +848,14 @@ onUnmounted(() => {
|
|||||||
:first-new-id="library.firstNewId.value"
|
:first-new-id="library.firstNewId.value"
|
||||||
:in-article-ids="inArticleIds"
|
:in-article-ids="inArticleIds"
|
||||||
:selected="selected"
|
:selected="selected"
|
||||||
:plain="choose !== undefined"
|
:none-chosen="noneChosen"
|
||||||
|
:field="field"
|
||||||
|
:plain="choose !== undefined && !field"
|
||||||
:tile-ratio="choose && !choose.square ? 'landscape' : 'square'"
|
:tile-ratio="choose && !choose.square ? 'landscape' : 'square'"
|
||||||
:hint="choose?.hint"
|
:hint="choose?.hint"
|
||||||
@search="searchLibrary"
|
@search="searchLibrary"
|
||||||
@select="selectItem"
|
@select="selectItem"
|
||||||
|
@select-none="selectNone"
|
||||||
@load-more="library.loadMore"
|
@load-more="library.loadMore"
|
||||||
@retry="library.load()"
|
@retry="library.load()"
|
||||||
@upload="selectTab('upload')"
|
@upload="selectTab('upload')"
|
||||||
@@ -1128,7 +1166,19 @@ onUnmounted(() => {
|
|||||||
{{ cancelLabel }}
|
{{ cancelLabel }}
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
v-if="onLibraryTab"
|
v-if="onLibraryTab && field"
|
||||||
|
block
|
||||||
|
:class="[
|
||||||
|
FOOTER_BUTTON_CLASS,
|
||||||
|
canConfirm ? '' : 'cursor-not-allowed opacity-40',
|
||||||
|
]"
|
||||||
|
:aria-disabled="canConfirm ? undefined : 'true'"
|
||||||
|
@click="confirmField"
|
||||||
|
>
|
||||||
|
{{ noneChosen ? t('Remove image') : t('Use this image') }}
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-else-if="onLibraryTab"
|
||||||
block
|
block
|
||||||
:class="FOOTER_BUTTON_CLASS"
|
:class="FOOTER_BUTTON_CLASS"
|
||||||
:disabled="!selected"
|
:disabled="!selected"
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed, nextTick, ref, useId } from 'vue'
|
||||||
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
|
import DirectoryField from '@/components/DirectoryField.vue'
|
||||||
|
import ImageUploader from '@/components/ImageUploader.vue'
|
||||||
|
import type { MediaItem } from '@/composables/useMedia'
|
||||||
|
import {
|
||||||
|
imageButtonName,
|
||||||
|
imageMeta,
|
||||||
|
imageOfItem,
|
||||||
|
isMissingImage,
|
||||||
|
type MediaImage,
|
||||||
|
type MediaSummary,
|
||||||
|
} from '@/lib/mediaField'
|
||||||
|
|
||||||
|
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||||
|
const BUTTON_CLASS = 'h-11 gap-1.5 px-3 text-sm text-highlighted disabled:opacity-40 md:h-8'
|
||||||
|
const OUTLINE_CLASS = `${BUTTON_CLASS} border border-accented bg-default ring-0`
|
||||||
|
const ROW_BUTTON_CLASS = `${BUTTON_CLASS} max-md:flex-1`
|
||||||
|
const ROW_OUTLINE_CLASS = `${OUTLINE_CLASS} max-md:flex-1`
|
||||||
|
const FOCUS_RING =
|
||||||
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
required?: boolean
|
||||||
|
image?: MediaSummary | null
|
||||||
|
disabled?: boolean
|
||||||
|
error?: string
|
||||||
|
name?: string
|
||||||
|
}>(),
|
||||||
|
{ required: false, image: null, disabled: false, error: '', name: undefined },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
change: [id: number | null, image: MediaSummary | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = defineModel<number | null>({ default: null })
|
||||||
|
|
||||||
|
const id = useId()
|
||||||
|
const labelId = `${id}-label`
|
||||||
|
const helpId = `${id}-help`
|
||||||
|
const errorId = `${id}-error`
|
||||||
|
|
||||||
|
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||||
|
const pickerOpen = ref(false)
|
||||||
|
const hovered = ref(false)
|
||||||
|
const announcement = ref('')
|
||||||
|
const mainButton = ref<{ $el: HTMLElement } | null>(null)
|
||||||
|
let opener: HTMLElement | null = null
|
||||||
|
let focusMain = false
|
||||||
|
|
||||||
|
const state = computed<'empty' | 'filled' | 'missing'>(() => {
|
||||||
|
if (modelValue.value === null) {
|
||||||
|
return 'empty'
|
||||||
|
}
|
||||||
|
return props.image && !isMissingImage(props.image) ? 'filled' : 'missing'
|
||||||
|
})
|
||||||
|
const filled = computed(() => (state.value === 'filled' ? (props.image as MediaImage) : null))
|
||||||
|
const meta = computed(() => (filled.value ? imageMeta(filled.value) : ''))
|
||||||
|
const help = computed(() => t('One image from your library.'))
|
||||||
|
const missingHelp = computed(() =>
|
||||||
|
props.required
|
||||||
|
? t('{0} is required. Choose another image to save.', [props.label])
|
||||||
|
: t('You can still save. The entry keeps the link until you change it.'),
|
||||||
|
)
|
||||||
|
const showHelp = computed(() => state.value !== 'missing')
|
||||||
|
const describedBy = computed(() => (props.error ? `${errorId} ${helpId}` : helpId))
|
||||||
|
const boxClass = computed(() => {
|
||||||
|
if (props.error) {
|
||||||
|
return 'border-error shadow-[inset_0_0_0_1px_var(--ui-error)]'
|
||||||
|
}
|
||||||
|
return state.value === 'empty' ? 'border-dashed border-accented' : 'border-accented'
|
||||||
|
})
|
||||||
|
const mainLabel = computed(() => {
|
||||||
|
if (state.value === 'empty') {
|
||||||
|
return t('Pick an image')
|
||||||
|
}
|
||||||
|
return state.value === 'filled' ? t('Change image') : t('Choose another image')
|
||||||
|
})
|
||||||
|
const mainAriaLabel = computed(() =>
|
||||||
|
state.value === 'empty' ? t('Pick an image for {0}', [props.label]) : undefined,
|
||||||
|
)
|
||||||
|
const mainIcon = computed(() =>
|
||||||
|
state.value === 'empty' ? 'i-lucide-image-plus' : 'i-lucide-image',
|
||||||
|
)
|
||||||
|
const thumbName = computed(() => (filled.value ? imageButtonName(filled.value) : undefined))
|
||||||
|
|
||||||
|
function announce(message: string) {
|
||||||
|
announcement.value = ''
|
||||||
|
nextTick(() => {
|
||||||
|
announcement.value = message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusMainButton() {
|
||||||
|
mainButton.value?.$el.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleFocus() {
|
||||||
|
await nextTick()
|
||||||
|
focusMainButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPicker(event?: Event) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opener = (event?.currentTarget as HTMLElement | null) ?? mainButton.value?.$el ?? null
|
||||||
|
focusMain = false
|
||||||
|
hovered.value = false
|
||||||
|
pickerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function change(id: number | null, image: MediaSummary | null) {
|
||||||
|
modelValue.value = id
|
||||||
|
emit('change', id, image)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(item: MediaItem) {
|
||||||
|
focusMain = true
|
||||||
|
if (item.id === modelValue.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
change(item.id, imageOfItem(item))
|
||||||
|
announce(t('Image selected: {0}', [item.filename]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChosen(item: MediaItem | null) {
|
||||||
|
if (item) {
|
||||||
|
pick(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
focusMain = true
|
||||||
|
if (modelValue.value !== null) {
|
||||||
|
change(null, null)
|
||||||
|
announce(t('Image removed from {0}.', [props.label]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPickerClosed() {
|
||||||
|
await nextTick()
|
||||||
|
const target = focusMain ? mainButton.value?.$el : opener
|
||||||
|
;(target?.isConnected ? target : mainButton.value?.$el)?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop(message: string) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
change(null, null)
|
||||||
|
announce(message)
|
||||||
|
settleFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove() {
|
||||||
|
drop(t('Image removed from {0}.', [props.label]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
drop(t('{0} cleared.', [props.label]))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<DirectoryField
|
||||||
|
:label="label"
|
||||||
|
:required="required"
|
||||||
|
:name="name"
|
||||||
|
:label-id="labelId"
|
||||||
|
:error="error"
|
||||||
|
:error-id="errorId"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
:aria-labelledby="labelId"
|
||||||
|
data-testid="media-control"
|
||||||
|
class="border bg-default p-3"
|
||||||
|
:class="boxClass"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="state === 'empty'"
|
||||||
|
class="flex flex-col gap-3 md:flex-row md:items-center"
|
||||||
|
data-testid="media-empty"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="flex size-10 shrink-0 items-center justify-center bg-elevated text-toned"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-image" class="size-5" />
|
||||||
|
</span>
|
||||||
|
<span class="text-muted">{{ t('No image selected.') }}</span>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
ref="mainButton"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
:icon="mainIcon"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="mainAriaLabel"
|
||||||
|
:aria-required="required ? 'true' : undefined"
|
||||||
|
:aria-invalid="error ? 'true' : undefined"
|
||||||
|
:aria-describedby="describedBy"
|
||||||
|
data-field-focus
|
||||||
|
:class="OUTLINE_CLASS"
|
||||||
|
@click="openPicker"
|
||||||
|
>
|
||||||
|
{{ mainLabel }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-col gap-3 md:flex-row md:items-start md:gap-4"
|
||||||
|
:data-testid="state === 'filled' ? 'media-filled' : 'media-missing'"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="filled"
|
||||||
|
type="button"
|
||||||
|
data-testid="media-thumbnail"
|
||||||
|
:aria-label="thumbName"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="relative w-full shrink-0 overflow-hidden border bg-elevated max-md:aspect-video md:h-30 md:w-40"
|
||||||
|
:class="[
|
||||||
|
hovered && isDesktop ? 'border-(--ui-text-muted)' : 'border-accented',
|
||||||
|
disabled ? 'opacity-60' : '',
|
||||||
|
FOCUS_RING,
|
||||||
|
]"
|
||||||
|
@click="openPicker"
|
||||||
|
@pointerenter="hovered = true"
|
||||||
|
@pointerleave="hovered = false"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="filled.thumbnail_url"
|
||||||
|
alt=""
|
||||||
|
class="absolute inset-0 size-full object-cover"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="hovered && isDesktop && !disabled"
|
||||||
|
data-testid="media-hover-bar"
|
||||||
|
class="absolute inset-x-0 bottom-0 flex h-7 items-center justify-center gap-1.5 bg-inverted text-xs font-medium text-inverted"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-image" class="size-3.5" />
|
||||||
|
{{ t('Change image') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
role="img"
|
||||||
|
:aria-label="t('Image no longer in the library')"
|
||||||
|
data-testid="media-placeholder"
|
||||||
|
class="flex w-full shrink-0 items-center justify-center border border-dashed border-accented text-muted max-md:aspect-video md:h-30 md:w-40"
|
||||||
|
:class="disabled ? 'opacity-60' : ''"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-image-off" class="size-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-3 md:min-h-30 md:justify-between">
|
||||||
|
<div v-if="filled" class="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span class="truncate font-medium text-highlighted">{{
|
||||||
|
filled.filename
|
||||||
|
}}</span>
|
||||||
|
<span class="text-xs/4.5 text-muted" data-testid="media-meta">{{
|
||||||
|
meta
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-col gap-1">
|
||||||
|
<span class="font-medium text-highlighted">{{
|
||||||
|
t('This image is no longer in the library')
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
:id="helpId"
|
||||||
|
class="text-xs/4.5 text-pretty text-muted"
|
||||||
|
data-testid="media-missing-help"
|
||||||
|
>{{ missingHelp }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<UButton
|
||||||
|
ref="mainButton"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
:icon="mainIcon"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-required="required ? 'true' : undefined"
|
||||||
|
:aria-invalid="error ? 'true' : undefined"
|
||||||
|
:aria-describedby="describedBy"
|
||||||
|
data-field-focus
|
||||||
|
:class="ROW_OUTLINE_CLASS"
|
||||||
|
@click="openPicker"
|
||||||
|
>
|
||||||
|
{{ mainLabel }}
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="filled"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-lucide-x"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="t('Remove image from {0}', [label])"
|
||||||
|
:class="ROW_BUTTON_CLASS"
|
||||||
|
@click="remove"
|
||||||
|
>
|
||||||
|
{{ t('Remove') }}
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-else
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="t('Clear {0}', [label])"
|
||||||
|
:class="ROW_BUTTON_CLASS"
|
||||||
|
@click="clear"
|
||||||
|
>
|
||||||
|
{{ t('Clear') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="showHelp"
|
||||||
|
:id="helpId"
|
||||||
|
data-testid="media-help"
|
||||||
|
class="mt-1.5 text-xs/4.5 text-pretty text-muted"
|
||||||
|
:class="error ? 'sr-only' : ''"
|
||||||
|
>
|
||||||
|
{{ help }}
|
||||||
|
</p>
|
||||||
|
<span class="sr-only" role="status" aria-live="polite" data-testid="media-live">{{
|
||||||
|
announcement
|
||||||
|
}}</span>
|
||||||
|
</DirectoryField>
|
||||||
|
|
||||||
|
<ImageUploader
|
||||||
|
v-model:open="pickerOpen"
|
||||||
|
:field="{ optional: !required, currentId: modelValue }"
|
||||||
|
@chosen="onChosen"
|
||||||
|
@uploaded="pick"
|
||||||
|
@closed="onPickerClosed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -8,6 +8,7 @@ import VariantPickerField from '@/components/variants/VariantPickerField.vue'
|
|||||||
import type { LibraryStatus } from '@/composables/useLibraryList'
|
import type { LibraryStatus } from '@/composables/useLibraryList'
|
||||||
import type { ImageVariant } from '@/composables/useImageVariants'
|
import type { ImageVariant } from '@/composables/useImageVariants'
|
||||||
import type { MediaItem } from '@/composables/useMedia'
|
import type { MediaItem } from '@/composables/useMedia'
|
||||||
|
import type { FieldPick } from '@/lib/imageChoice'
|
||||||
import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat'
|
import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat'
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 120
|
const SEARCH_DEBOUNCE_MS = 120
|
||||||
@@ -41,11 +42,14 @@ const props = defineProps<{
|
|||||||
plain?: boolean
|
plain?: boolean
|
||||||
tileRatio?: 'square' | 'landscape'
|
tileRatio?: 'square' | 'landscape'
|
||||||
hint?: string
|
hint?: string
|
||||||
|
field?: FieldPick
|
||||||
|
noneChosen?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
search: [query: string]
|
search: [query: string]
|
||||||
select: [item: MediaItem]
|
select: [item: MediaItem]
|
||||||
|
'select-none': []
|
||||||
'load-more': []
|
'load-more': []
|
||||||
retry: []
|
retry: []
|
||||||
upload: []
|
upload: []
|
||||||
@@ -71,12 +75,19 @@ const searching = computed(() => props.query !== '')
|
|||||||
const summary = computed(() =>
|
const summary = computed(() =>
|
||||||
searching.value ? t('Newest first') : t('Newest first · {0} images', [props.total]),
|
searching.value ? t('Newest first') : t('Newest first · {0} images', [props.total]),
|
||||||
)
|
)
|
||||||
|
const chosen = computed(() => props.selected !== null || props.noneChosen)
|
||||||
const scrollHeight = computed(() => {
|
const scrollHeight = computed(() => {
|
||||||
if (props.plain) {
|
if (props.plain) {
|
||||||
return 'max-h-71 md:max-h-89'
|
return 'max-h-71 md:max-h-89'
|
||||||
}
|
}
|
||||||
return props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'
|
return chosen.value ? 'max-h-32 md:max-h-49.5' : 'max-h-77'
|
||||||
})
|
})
|
||||||
|
const showNoImage = computed(() => props.field?.optional === true && showGrid.value)
|
||||||
|
const emptyDescription = computed(() =>
|
||||||
|
props.field
|
||||||
|
? t('Images you upload appear here, ready to use in any article or entry.')
|
||||||
|
: t('Images you upload appear here, ready to use in any article.'),
|
||||||
|
)
|
||||||
const showEmptyLibrary = computed(
|
const showEmptyLibrary = computed(
|
||||||
() => props.status === 'ready' && props.items.length === 0 && !searching.value,
|
() => props.status === 'ready' && props.items.length === 0 && !searching.value,
|
||||||
)
|
)
|
||||||
@@ -85,7 +96,7 @@ const showNoResults = computed(
|
|||||||
)
|
)
|
||||||
const showGrid = computed(() => props.status === 'ready' && props.items.length > 0)
|
const showGrid = computed(() => props.status === 'ready' && props.items.length > 0)
|
||||||
const showLoadMore = computed(
|
const showLoadMore = computed(
|
||||||
() => showGrid.value && props.hasMore && (props.plain || !props.selected),
|
() => showGrid.value && props.hasMore && (props.plain || !chosen.value),
|
||||||
)
|
)
|
||||||
const noResultsTitle = computed(() => t('No images match \u0022{0}\u0022', [props.query]))
|
const noResultsTitle = computed(() => t('No images match \u0022{0}\u0022', [props.query]))
|
||||||
|
|
||||||
@@ -115,7 +126,19 @@ function isSelected(item: MediaItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isInArticle(item: MediaItem) {
|
function isInArticle(item: MediaItem) {
|
||||||
return props.inArticleIds.includes(item.id)
|
return !props.field && props.inArticleIds.includes(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrent(item: MediaItem) {
|
||||||
|
return props.field?.currentId === item.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function tileName(item: MediaItem) {
|
||||||
|
if (!props.field) {
|
||||||
|
return accessibleName(item)
|
||||||
|
}
|
||||||
|
const name = `${item.filename}, ${formatSize(item.size)}`
|
||||||
|
return isCurrent(item) ? `${name}, ${t('Current')}` : name
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTileKeydown(event: KeyboardEvent, item: MediaItem) {
|
function onTileKeydown(event: KeyboardEvent, item: MediaItem) {
|
||||||
@@ -206,7 +229,7 @@ onBeforeUnmount(clearDebounce)
|
|||||||
variant="naked"
|
variant="naked"
|
||||||
icon="i-lucide-images"
|
icon="i-lucide-images"
|
||||||
:title="t('Your library is empty')"
|
:title="t('Your library is empty')"
|
||||||
:description="t('Images you upload appear here, ready to use in any article.')"
|
:description="emptyDescription"
|
||||||
:ui="EMPTY_STATE_UI"
|
:ui="EMPTY_STATE_UI"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
@@ -253,6 +276,26 @@ onBeforeUnmount(clearDebounce)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="showGrid">
|
<template v-else-if="showGrid">
|
||||||
|
<button
|
||||||
|
v-if="showNoImage"
|
||||||
|
type="button"
|
||||||
|
data-testid="picker-no-image"
|
||||||
|
:aria-pressed="noneChosen"
|
||||||
|
class="flex min-h-10 items-center gap-2.5 border px-3 text-start focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary max-md:min-h-12"
|
||||||
|
:class="
|
||||||
|
noneChosen
|
||||||
|
? 'border-primary bg-(--rc-sand) text-(--rc-on-sand)'
|
||||||
|
: 'border-accented bg-default text-highlighted'
|
||||||
|
"
|
||||||
|
@click="emit('select-none')"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-image-off" class="size-4 shrink-0" />
|
||||||
|
<span class="font-medium">{{ t('No image') }}</span>
|
||||||
|
<span class="flex-1" :class="noneChosen ? 'text-(--rc-on-sand)' : 'text-muted'">{{
|
||||||
|
t('Leave this field empty.')
|
||||||
|
}}</span>
|
||||||
|
<UIcon v-if="noneChosen" name="i-lucide-check" class="size-4 shrink-0" />
|
||||||
|
</button>
|
||||||
<span v-if="!plain" data-testid="picker-summary" class="text-xs/5 text-muted">{{
|
<span v-if="!plain" data-testid="picker-summary" class="text-xs/5 text-muted">{{
|
||||||
summary
|
summary
|
||||||
}}</span>
|
}}</span>
|
||||||
@@ -268,7 +311,7 @@ onBeforeUnmount(clearDebounce)
|
|||||||
type="button"
|
type="button"
|
||||||
data-picker-tile
|
data-picker-tile
|
||||||
:data-image-id="item.id"
|
:data-image-id="item.id"
|
||||||
:aria-label="accessibleName(item)"
|
:aria-label="tileName(item)"
|
||||||
:aria-pressed="isSelected(item)"
|
:aria-pressed="isSelected(item)"
|
||||||
class="group flex min-w-0 flex-col gap-1.5 text-start focus-visible:outline-none"
|
class="group flex min-w-0 flex-col gap-1.5 text-start focus-visible:outline-none"
|
||||||
@click="emit('select', item)"
|
@click="emit('select', item)"
|
||||||
@@ -287,6 +330,11 @@ onBeforeUnmount(clearDebounce)
|
|||||||
class="w-full border border-default"
|
class="w-full border border-default"
|
||||||
:class="tileAspect"
|
:class="tileAspect"
|
||||||
/>
|
/>
|
||||||
|
<span
|
||||||
|
v-if="isCurrent(item)"
|
||||||
|
class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-(--rc-sand) px-1.25 text-[0.6875rem] font-medium text-(--rc-on-sand)"
|
||||||
|
>{{ t('Current') }}</span
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
v-if="isInArticle(item)"
|
v-if="isInArticle(item)"
|
||||||
class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-secondary-200 px-1.25 text-[0.6875rem] font-medium text-primary-900 dark:bg-secondary-300"
|
class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-secondary-200 px-1.25 text-[0.6875rem] font-medium text-primary-900 dark:bg-secondary-300"
|
||||||
@@ -338,7 +386,7 @@ onBeforeUnmount(clearDebounce)
|
|||||||
<p v-if="hint" class="text-xs/5 text-muted">{{ hint }}</p>
|
<p v-if="hint" class="text-xs/5 text-muted">{{ hint }}</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="selected && !plain"
|
v-if="selected && !plain && !noneChosen"
|
||||||
data-testid="picker-panel"
|
data-testid="picker-panel"
|
||||||
class="flex flex-col gap-3 border-t border-default pt-3"
|
class="flex flex-col gap-3 border-t border-default pt-3"
|
||||||
>
|
>
|
||||||
@@ -355,12 +403,14 @@ onBeforeUnmount(clearDebounce)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VariantPickerField
|
<VariantPickerField
|
||||||
|
v-if="!field"
|
||||||
v-model:variant="variant"
|
v-model:variant="variant"
|
||||||
:image="selected"
|
:image="selected"
|
||||||
:variants="variants ?? []"
|
:variants="variants ?? []"
|
||||||
:ready="variantsReady ?? false"
|
:ready="variantsReady ?? false"
|
||||||
/>
|
/>
|
||||||
<UFormField
|
<UFormField
|
||||||
|
v-if="!field"
|
||||||
:label="t('Alt text (optional)')"
|
:label="t('Alt text (optional)')"
|
||||||
:help="
|
:help="
|
||||||
t(
|
t(
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ const badge = computed(() => {
|
|||||||
if (props.cause) {
|
if (props.cause) {
|
||||||
return t('Possible cause')
|
return t('Possible cause')
|
||||||
}
|
}
|
||||||
|
if (props.change.flag === 'cannotApply') {
|
||||||
|
return t("Can't be applied")
|
||||||
|
}
|
||||||
if (props.change.flag === 'mayNotFit') {
|
if (props.change.flag === 'mayNotFit') {
|
||||||
return t('Entries may not fit')
|
return t('Entries may not fit')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { computed } from 'vue'
|
|||||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||||
import SchemaChangeList from '@/components/SchemaChangeList.vue'
|
import SchemaChangeList from '@/components/SchemaChangeList.vue'
|
||||||
import { entriesLine } from '@/lib/schemaEntries'
|
import { entriesLine } from '@/lib/schemaEntries'
|
||||||
import { summaryLine, type FieldChange } from '@/lib/schemaBuilderDiff'
|
import {
|
||||||
|
hasRefusedChange,
|
||||||
|
isMediaOnlyClean,
|
||||||
|
summaryLine,
|
||||||
|
type FieldChange,
|
||||||
|
} from '@/lib/schemaBuilderDiff'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
name: string
|
name: string
|
||||||
@@ -19,6 +24,8 @@ defineEmits<{ apply: []; close: [] }>()
|
|||||||
|
|
||||||
const summary = computed(() => summaryLine(props.changes))
|
const summary = computed(() => summaryLine(props.changes))
|
||||||
const entries = computed(() => entriesLine(props.name, props.total))
|
const entries = computed(() => entriesLine(props.name, props.total))
|
||||||
|
const refused = computed(() => hasRefusedChange(props.changes))
|
||||||
|
const mediaOnlyClean = computed(() => isMediaOnlyClean(props.changes))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -32,6 +39,7 @@ const entries = computed(() => entriesLine(props.name, props.total))
|
|||||||
wide
|
wide
|
||||||
:pending="applying"
|
:pending="applying"
|
||||||
:confirm-disabled="offline"
|
:confirm-disabled="offline"
|
||||||
|
:confirm-blocked="refused"
|
||||||
@confirm="$emit('apply')"
|
@confirm="$emit('apply')"
|
||||||
@close="$emit('close')"
|
@close="$emit('close')"
|
||||||
>
|
>
|
||||||
@@ -42,7 +50,10 @@ const entries = computed(() => entriesLine(props.name, props.total))
|
|||||||
<span class="text-muted">{{ entries }}</span>
|
<span class="text-muted">{{ entries }}</span>
|
||||||
</div>
|
</div>
|
||||||
<SchemaChangeList :changes="changes" test-id="review-changes" />
|
<SchemaChangeList :changes="changes" test-id="review-changes" />
|
||||||
<p v-if="!applying" class="text-xs/4.5 text-muted">
|
<p v-if="refused" class="text-xs/4.5 text-muted">
|
||||||
|
{{ t('Back to editing keeps your other changes.') }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="!mediaOnlyClean && !applying" class="text-xs/4.5 text-muted">
|
||||||
{{
|
{{
|
||||||
t(
|
t(
|
||||||
"Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed.",
|
"Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed.",
|
||||||
|
|||||||
@@ -111,3 +111,59 @@ describe('FieldCell', () => {
|
|||||||
expect(element.className).toContain('text-muted')
|
expect(element.className).toContain('text-muted')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('FieldCell for media', () => {
|
||||||
|
const LIME = {
|
||||||
|
id: 7,
|
||||||
|
filename: 'lime-plaster-workshop.jpg',
|
||||||
|
width: 1600,
|
||||||
|
height: 900,
|
||||||
|
size: 245760,
|
||||||
|
thumbnail_url: '/media/lime-plaster-workshop.jpg',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showMedia(value: unknown, image: unknown, compact = false) {
|
||||||
|
await mountInApp(FieldCell, {
|
||||||
|
field: apiField('Thumbnail', 'thumbnail', 'media'),
|
||||||
|
value,
|
||||||
|
image,
|
||||||
|
compact,
|
||||||
|
})
|
||||||
|
return cell()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('draws a 40 px thumbnail', async () => {
|
||||||
|
const element = await showMedia(7, LIME)
|
||||||
|
|
||||||
|
const img = element.querySelector('img')!
|
||||||
|
expect(img.getAttribute('src')).toBe(LIME.thumbnail_url)
|
||||||
|
expect(img.getAttribute('alt')).toBe(LIME.filename)
|
||||||
|
expect(element.className).toContain('size-10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws a 32 px thumbnail when compact', async () => {
|
||||||
|
const element = await showMedia(7, LIME, true)
|
||||||
|
|
||||||
|
expect(element.className).toContain('size-8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a muted dash for no image, centred in 32 px when compact', async () => {
|
||||||
|
const plain = await showMedia(null, null)
|
||||||
|
expect(text(plain)).toBe('–')
|
||||||
|
expect(plain.className).toContain('text-muted')
|
||||||
|
unmountApp()
|
||||||
|
|
||||||
|
const compact = await showMedia(null, null, true)
|
||||||
|
expect(compact.className).toContain('w-8')
|
||||||
|
expect(compact.className).toContain('text-center')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the dashed placeholder with its name for a missing image', async () => {
|
||||||
|
const element = await showMedia(7, { id: 7, missing: true })
|
||||||
|
|
||||||
|
expect(element.getAttribute('role')).toBe('img')
|
||||||
|
expect(element.getAttribute('aria-label')).toBe('Image no longer in the library')
|
||||||
|
expect(element.className).toContain('border-dashed')
|
||||||
|
expect(element.className).toContain('size-10')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { addCollection } from '@iconify/vue'
|
||||||
|
import lucide from 'virtual:lucide-subset'
|
||||||
|
import { ApiError, NetworkError } from '@/composables/useApi'
|
||||||
|
import { drawsIcon, fieldLibrary } from '@/views/__tests__/mediaFieldKit'
|
||||||
|
import { DESKTOP_WIDTH, MOBILE_WIDTH } from './viewport'
|
||||||
|
import {
|
||||||
|
altField,
|
||||||
|
apiKit,
|
||||||
|
click,
|
||||||
|
clickTab,
|
||||||
|
dialogText,
|
||||||
|
findButton,
|
||||||
|
installApi,
|
||||||
|
listCalls,
|
||||||
|
modalOpen,
|
||||||
|
mountUploader,
|
||||||
|
openUploader,
|
||||||
|
pageBody,
|
||||||
|
press,
|
||||||
|
searchField,
|
||||||
|
secondPage,
|
||||||
|
selectedTab,
|
||||||
|
selectTile,
|
||||||
|
settle,
|
||||||
|
skeletonTiles,
|
||||||
|
tabButtons,
|
||||||
|
textOf,
|
||||||
|
tile,
|
||||||
|
tiles,
|
||||||
|
typeInto,
|
||||||
|
type PickerRoutes,
|
||||||
|
} from './pickerKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => 'a'.repeat(64)) }))
|
||||||
|
|
||||||
|
addCollection(lucide)
|
||||||
|
|
||||||
|
let wrapper: ReturnType<typeof mountUploader> | null = null
|
||||||
|
const RealXMLHttpRequest = global.XMLHttpRequest
|
||||||
|
|
||||||
|
class FakeUpload {
|
||||||
|
static last: FakeUpload | null = null
|
||||||
|
listeners: Record<string, Array<() => void>> = {}
|
||||||
|
status = 0
|
||||||
|
responseText = ''
|
||||||
|
upload = { addEventListener: () => undefined }
|
||||||
|
open() {}
|
||||||
|
constructor() {
|
||||||
|
FakeUpload.last = this
|
||||||
|
}
|
||||||
|
addEventListener(type: string, listener: () => void) {
|
||||||
|
this.listeners[type] = [...(this.listeners[type] ?? []), listener]
|
||||||
|
}
|
||||||
|
send() {}
|
||||||
|
abort() {}
|
||||||
|
answer(status: number, body: string) {
|
||||||
|
this.status = status
|
||||||
|
this.responseText = body
|
||||||
|
this.listeners.load?.forEach((listener) => listener())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
global.XMLHttpRequest = FakeUpload as unknown as typeof XMLHttpRequest
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
global.XMLHttpRequest = RealXMLHttpRequest
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function libraryRoutes(extra: PickerRoutes = {}): PickerRoutes {
|
||||||
|
return { list: () => pageBody(fieldLibrary()), ...extra }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openField(
|
||||||
|
field: { optional: boolean; currentId: number | null } = { optional: true, currentId: 1 },
|
||||||
|
routes: PickerRoutes = libraryRoutes(),
|
||||||
|
width = DESKTOP_WIDTH,
|
||||||
|
) {
|
||||||
|
installApi(routes)
|
||||||
|
wrapper = mountUploader({ field }, width)
|
||||||
|
await openUploader(wrapper)
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = () => textOf(document.body.querySelector('[data-testid="picker-summary"]'))
|
||||||
|
const strip = () => document.body.querySelector('[data-testid="picker-panel"]')
|
||||||
|
const noImage = () =>
|
||||||
|
document.body.querySelector<HTMLButtonElement>('[data-testid="picker-no-image"]')
|
||||||
|
const variantRequests = () =>
|
||||||
|
apiKit.request.mock.calls.filter(([path]) => String(path).includes('/v1/image-variants'))
|
||||||
|
|
||||||
|
describe('The dialog of a field', () => {
|
||||||
|
it('is titled Choose an image, with the tabs Upload and Library opening on Library', async () => {
|
||||||
|
await openField()
|
||||||
|
|
||||||
|
expect(dialogText()).toContain('Choose an image')
|
||||||
|
expect(dialogText()).not.toContain('Add an image')
|
||||||
|
expect(tabButtons().map(textOf)).toEqual(['Upload', 'Library'])
|
||||||
|
expect(textOf(selectedTab())).toBe('Library')
|
||||||
|
expect(searchField().placeholder).toBe('Search by filename')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the newest first line with the count, square tiles and Load more', async () => {
|
||||||
|
await openField()
|
||||||
|
|
||||||
|
expect(summary()).toBe('Newest first · 48 images')
|
||||||
|
expect(tiles()).toHaveLength(12)
|
||||||
|
expect(findButton('Load more')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
const thumb = tile('north-wall.jpg').querySelector('[data-testid="media-thumb"]')!
|
||||||
|
expect(thumb.className).toContain('aspect-square')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has 4 columns on desktop and 3 on mobile', async () => {
|
||||||
|
await openField()
|
||||||
|
expect(tiles()[0]!.parentElement!.className).toContain('md:grid-cols-4')
|
||||||
|
wrapper!.unmount()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
|
||||||
|
await openField(undefined, undefined, MOBILE_WIDTH)
|
||||||
|
expect(tiles()[0]!.parentElement!.className).toContain('grid-cols-3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens with Cancel enabled and Use this image announced as disabled', async () => {
|
||||||
|
await openField()
|
||||||
|
|
||||||
|
expect(findButton('Cancel')?.disabled).toBe(false)
|
||||||
|
expect(findButton('Use this image')?.getAttribute('aria-disabled')).toBe('true')
|
||||||
|
await click(findButton('Use this image'))
|
||||||
|
expect(wrapper!.emitted('chosen')).toBeUndefined()
|
||||||
|
expect(modalOpen()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never asks for the variants and shows neither a variant select nor an alt text', async () => {
|
||||||
|
await openField()
|
||||||
|
await selectTile('north-wall.jpg')
|
||||||
|
|
||||||
|
expect(variantRequests()).toHaveLength(0)
|
||||||
|
expect(altField()).toBeNull()
|
||||||
|
expect(dialogText()).not.toContain('Alt text')
|
||||||
|
expect(dialogText()).not.toContain('Variant')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The No image row', () => {
|
||||||
|
it('tops the Library tab of an optional field', async () => {
|
||||||
|
await openField({ optional: true, currentId: 1 })
|
||||||
|
|
||||||
|
const row = noImage()!
|
||||||
|
expect(textOf(row)).toBe('No image Leave this field empty.')
|
||||||
|
expect(row.getAttribute('aria-pressed')).toBe('false')
|
||||||
|
expect(drawsIcon(row, 'image-off')).toBe(true)
|
||||||
|
const order = [
|
||||||
|
...document.body.querySelectorAll('input, [data-testid], [data-picker-tile]'),
|
||||||
|
]
|
||||||
|
expect(order.indexOf(searchField())).toBeLessThan(order.indexOf(row))
|
||||||
|
expect(order.indexOf(row)).toBeLessThan(order.indexOf(tiles()[0]!))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is absent on a required field, whose title stays Choose an image', async () => {
|
||||||
|
await openField({ optional: false, currentId: 1 })
|
||||||
|
|
||||||
|
expect(noImage()).toBeNull()
|
||||||
|
expect(dialogText()).toContain('Choose an image')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['empty', { list: () => pageBody([], null) }],
|
||||||
|
['failed', { list: () => Promise.reject(new ApiError(500)) }],
|
||||||
|
['loading', { list: () => new Promise(() => undefined) }],
|
||||||
|
])('is absent while the library is %s', async (_name, routes) => {
|
||||||
|
await openField({ optional: true, currentId: null }, routes as PickerRoutes)
|
||||||
|
|
||||||
|
expect(noImage()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('takes the sand fill, the primary border and a check when chosen, releasing any tile', async () => {
|
||||||
|
await openField()
|
||||||
|
await selectTile('kitchen-after.webp')
|
||||||
|
|
||||||
|
await click(noImage())
|
||||||
|
|
||||||
|
const row = noImage()!
|
||||||
|
expect(row.getAttribute('aria-pressed')).toBe('true')
|
||||||
|
expect(row.className).toContain('bg-(--rc-sand)')
|
||||||
|
expect(row.className).toContain('border-primary')
|
||||||
|
expect(drawsIcon(row, 'check')).toBe(true)
|
||||||
|
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('false')
|
||||||
|
expect(strip()).toBeNull()
|
||||||
|
expect(findButton('Load more')).toBeUndefined()
|
||||||
|
expect(findButton('Remove image')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
expect(findButton('Use this image')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is released when a tile is chosen again', async () => {
|
||||||
|
await openField()
|
||||||
|
await click(noImage())
|
||||||
|
|
||||||
|
await selectTile('kitchen-after.webp')
|
||||||
|
|
||||||
|
expect(noImage()!.getAttribute('aria-pressed')).toBe('false')
|
||||||
|
expect(findButton('Use this image')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits the removal and closes with Remove image', async () => {
|
||||||
|
await openField()
|
||||||
|
await click(noImage())
|
||||||
|
|
||||||
|
await click(findButton('Remove image'))
|
||||||
|
|
||||||
|
expect(wrapper!.emitted('chosen')).toEqual([[null]])
|
||||||
|
await vi.waitFor(() => expect(modalOpen()).toBe(false))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Tiles of a field', () => {
|
||||||
|
it('mark the current image with the sand badge instead of In this article', async () => {
|
||||||
|
await openField({ optional: true, currentId: 1 })
|
||||||
|
|
||||||
|
const current = tile('north-wall.jpg')
|
||||||
|
const badge = current.querySelector<HTMLElement>('span[class*="bottom-1"]')!
|
||||||
|
expect(textOf(badge)).toBe('Current')
|
||||||
|
expect(badge.className).toContain('bg-(--rc-sand)')
|
||||||
|
expect(badge.className).toContain('text-(--rc-on-sand)')
|
||||||
|
expect(badge.className).toContain('h-4.5')
|
||||||
|
expect(badge.className).toContain('bottom-1')
|
||||||
|
expect(badge.className).toContain('left-1')
|
||||||
|
expect(dialogText()).not.toContain('In this article')
|
||||||
|
expect(tiles().filter((element) => textOf(element).includes('Current'))).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('are buttons named with the filename and size, plus Current for the current image', async () => {
|
||||||
|
await openField({ optional: true, currentId: 1 })
|
||||||
|
|
||||||
|
expect(tile('kitchen-after.webp').getAttribute('aria-label')).toBe(
|
||||||
|
'kitchen-after.webp, 1.2 MB',
|
||||||
|
)
|
||||||
|
expect(tiles()[0]!.getAttribute('aria-label')).toBe('north-wall.jpg, 3.9 MB, Current')
|
||||||
|
expect(
|
||||||
|
tiles().every((element) => !element.getAttribute('aria-label')!.includes('used')),
|
||||||
|
).toBe(true)
|
||||||
|
expect(tiles().every((element) => element.hasAttribute('aria-pressed'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keep the article picker names and badges unchanged', async () => {
|
||||||
|
installApi(libraryRoutes())
|
||||||
|
wrapper = mountUploader({})
|
||||||
|
await openUploader(wrapper)
|
||||||
|
|
||||||
|
expect(dialogText()).toContain('Add an image')
|
||||||
|
expect(tiles()[0]!.getAttribute('aria-label')).toContain('used in')
|
||||||
|
expect(dialogText()).toContain('In this article')
|
||||||
|
expect(noImage()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Choosing an image', () => {
|
||||||
|
it('gives the tile the primary outline and a check, and shows the strip below the grid', async () => {
|
||||||
|
await openField()
|
||||||
|
|
||||||
|
await selectTile('lime-plaster-workshop.jpg')
|
||||||
|
|
||||||
|
const chosen = tile('lime-plaster-workshop.jpg')
|
||||||
|
expect(chosen.getAttribute('aria-pressed')).toBe('true')
|
||||||
|
expect(chosen.querySelector('[data-testid="picker-check"]')).not.toBeNull()
|
||||||
|
expect(chosen.innerHTML).toContain('outline-primary')
|
||||||
|
expect(textOf(strip())).toBe('lime-plaster-workshop.jpg 1600 × 900 · 240 KB')
|
||||||
|
expect(strip()!.querySelector('[data-testid="media-thumb"]')!.className).toContain(
|
||||||
|
'size-14',
|
||||||
|
)
|
||||||
|
expect(strip()!.querySelector('input, select, [role="combobox"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws the strip of a vector image as Vector', async () => {
|
||||||
|
await openField()
|
||||||
|
|
||||||
|
await selectTile('logo-renew.svg')
|
||||||
|
|
||||||
|
expect(textOf(strip())).toBe('logo-renew.svg Vector · 8 KB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Load more while a tile is selected and shows it again after a search release', async () => {
|
||||||
|
await openField()
|
||||||
|
expect(findButton('Load more')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
|
||||||
|
await selectTile('north-wall.jpg')
|
||||||
|
|
||||||
|
expect(findButton('Load more')).toBeUndefined()
|
||||||
|
expect(findButton('Use this image')?.getAttribute('aria-disabled')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits the item and closes with Use this image', async () => {
|
||||||
|
await openField()
|
||||||
|
await selectTile('bathroom-tiles.png')
|
||||||
|
|
||||||
|
await click(findButton('Use this image'))
|
||||||
|
|
||||||
|
const [[item]] = wrapper!.emitted('chosen') as [[{ id: number; filename: string }]]
|
||||||
|
expect(item.id).toBe(8)
|
||||||
|
expect(item.filename).toBe('bathroom-tiles.png')
|
||||||
|
await vi.waitFor(() => expect(modalOpen()).toBe(false))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets the user pick the current tile again', async () => {
|
||||||
|
await openField({ optional: true, currentId: 1 })
|
||||||
|
|
||||||
|
await selectTile('north-wall.jpg')
|
||||||
|
await click(findButton('Use this image'))
|
||||||
|
|
||||||
|
expect(wrapper!.emitted('chosen')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects with Enter and Space', async () => {
|
||||||
|
await openField()
|
||||||
|
|
||||||
|
press(tile('north-wall.jpg'), 'Enter')
|
||||||
|
await settle()
|
||||||
|
expect(tile('north-wall.jpg').getAttribute('aria-pressed')).toBe('true')
|
||||||
|
|
||||||
|
press(tile('kitchen-after.webp'), ' ')
|
||||||
|
await settle()
|
||||||
|
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('true')
|
||||||
|
expect(tile('north-wall.jpg').getAttribute('aria-pressed')).toBe('false')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Closing without choosing', () => {
|
||||||
|
it.each([
|
||||||
|
['Cancel', async () => click(findButton('Cancel'))],
|
||||||
|
[
|
||||||
|
'Escape',
|
||||||
|
async () => {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||||
|
)
|
||||||
|
await settle()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])('emits nothing with %s and reports the close', async (_name, close) => {
|
||||||
|
await openField()
|
||||||
|
await selectTile('north-wall.jpg')
|
||||||
|
|
||||||
|
await close()
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(modalOpen()).toBe(false))
|
||||||
|
expect(wrapper!.emitted('chosen')).toBeUndefined()
|
||||||
|
expect(wrapper!.emitted('closed')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Library states of a field', () => {
|
||||||
|
it('shows the field copy of the empty library and Upload an image opens the Upload tab', async () => {
|
||||||
|
await openField({ optional: true, currentId: null }, { list: () => pageBody([], null) })
|
||||||
|
expect(textOf(selectedTab())).toBe('Upload')
|
||||||
|
|
||||||
|
await clickTab('Library')
|
||||||
|
|
||||||
|
expect(dialogText()).toContain('Your library is empty')
|
||||||
|
expect(dialogText()).toContain(
|
||||||
|
'Images you upload appear here, ready to use in any article or entry.',
|
||||||
|
)
|
||||||
|
expect(findButton('Use this image')?.getAttribute('aria-disabled')).toBe('true')
|
||||||
|
await click(findButton('Upload an image'))
|
||||||
|
expect(textOf(selectedTab())).toBe('Upload')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the article copy of the empty library', async () => {
|
||||||
|
installApi({ list: () => pageBody([], null) })
|
||||||
|
wrapper = mountUploader({})
|
||||||
|
await openUploader(wrapper)
|
||||||
|
await clickTab('Library')
|
||||||
|
|
||||||
|
expect(dialogText()).toContain(
|
||||||
|
'Images you upload appear here, ready to use in any article.',
|
||||||
|
)
|
||||||
|
expect(dialogText()).not.toContain('or entry')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['500', () => Promise.reject(new ApiError(500))],
|
||||||
|
['a network error', () => Promise.reject(new NetworkError())],
|
||||||
|
])('shows the error alert with Try again on %s and no No image row', async (_name, list) => {
|
||||||
|
await openField({ optional: true, currentId: 1 }, { list })
|
||||||
|
|
||||||
|
expect(dialogText()).toContain("Couldn't load your library")
|
||||||
|
expect(dialogText()).toContain('Check your connection and try again.')
|
||||||
|
expect(noImage()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('repeats only the library request with Try again', async () => {
|
||||||
|
let fail = true
|
||||||
|
await openField(
|
||||||
|
{ optional: true, currentId: 1 },
|
||||||
|
{ list: () => (fail ? Promise.reject(new ApiError(500)) : pageBody(fieldLibrary())) },
|
||||||
|
)
|
||||||
|
fail = false
|
||||||
|
const before = apiKit.request.mock.calls.length
|
||||||
|
|
||||||
|
await click(findButton('Try again'))
|
||||||
|
|
||||||
|
expect(apiKit.request.mock.calls.length - before).toBe(1)
|
||||||
|
expect(listCalls()).toHaveLength(2)
|
||||||
|
expect(tiles()).toHaveLength(12)
|
||||||
|
expect(dialogText()).not.toContain("Couldn't load your library")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws 8 skeleton tiles after 150 ms on desktop, 9 on mobile, with Use this image disabled', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
installApi({ list: () => new Promise(() => undefined) })
|
||||||
|
wrapper = mountUploader({ field: { optional: true, currentId: 1 } })
|
||||||
|
;(wrapper.vm as unknown as { open: boolean }).open = true
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
expect(skeletonTiles()).toHaveLength(0)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(150)
|
||||||
|
|
||||||
|
expect(skeletonTiles()).toHaveLength(8)
|
||||||
|
expect(noImage()).toBeNull()
|
||||||
|
expect(findButton('Use this image')?.getAttribute('aria-disabled')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws 9 skeleton tiles on mobile', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
installApi({ list: () => new Promise(() => undefined) })
|
||||||
|
wrapper = mountUploader({ field: { optional: true, currentId: 1 } }, MOBILE_WIDTH)
|
||||||
|
;(wrapper.vm as unknown as { open: boolean }).open = true
|
||||||
|
await vi.advanceTimersByTimeAsync(150)
|
||||||
|
|
||||||
|
expect(skeletonTiles()).toHaveLength(9)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims and debounces the search, releases the selection and reloads the grid', async () => {
|
||||||
|
await openField(undefined, {
|
||||||
|
list: (query) =>
|
||||||
|
query.get('q') ? pageBody(fieldLibrary().slice(0, 1), 1) : pageBody(fieldLibrary()),
|
||||||
|
})
|
||||||
|
await selectTile('kitchen-after.webp')
|
||||||
|
|
||||||
|
await typeInto(searchField(), ' wall ')
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(listCalls().map((query) => query.get('q'))).toEqual([null, 'wall'])
|
||||||
|
expect(tiles()).toHaveLength(1)
|
||||||
|
expect(strip()).toBeNull()
|
||||||
|
expect(findButton('Use this image')?.getAttribute('aria-disabled')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the no results copy with Clear search', async () => {
|
||||||
|
await openField(undefined, {
|
||||||
|
list: (query) => (query.get('q') ? pageBody([], 0) : pageBody(fieldLibrary())),
|
||||||
|
})
|
||||||
|
|
||||||
|
await typeInto(searchField(), 'wall')
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(dialogText()).toContain('No images match "wall"')
|
||||||
|
expect(findButton('Clear search')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves the focus to the first new tile after Load more', async () => {
|
||||||
|
await openField(undefined, {
|
||||||
|
list: (query) =>
|
||||||
|
query.get('page') === '2' ? pageBody(secondPage()) : pageBody(fieldLibrary()),
|
||||||
|
})
|
||||||
|
|
||||||
|
await click(findButton('Load more'))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(tiles()).toHaveLength(24)
|
||||||
|
expect(document.activeElement).toBe(tiles()[12])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The Upload tab of a field', () => {
|
||||||
|
it('shows the existing drop zone', async () => {
|
||||||
|
await openField()
|
||||||
|
|
||||||
|
await clickTab('Upload')
|
||||||
|
|
||||||
|
expect(dialogText()).toContain('Drop an image here, or choose a file')
|
||||||
|
expect(dialogText()).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
|
||||||
|
expect(findButton('Use this image')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function chooseSvg() {
|
||||||
|
const input = document.body.querySelector<HTMLInputElement>(
|
||||||
|
'[data-testid="image-file-input"]',
|
||||||
|
)!
|
||||||
|
const file = new File(['<svg/>'], 'diagram.svg', { type: 'image/svg+xml' })
|
||||||
|
Object.defineProperty(input, 'files', { value: { 0: file, length: 1 }, configurable: true })
|
||||||
|
input.dispatchEvent(new Event('change'))
|
||||||
|
await settle()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('emits the uploaded item and closes a moment later', async () => {
|
||||||
|
await openField(undefined, libraryRoutes())
|
||||||
|
await clickTab('Upload')
|
||||||
|
await chooseSvg()
|
||||||
|
const item = { ...fieldLibrary()[0]!, id: 13, filename: 'diagram.svg' }
|
||||||
|
|
||||||
|
FakeUpload.last!.answer(201, JSON.stringify(item))
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(wrapper!.emitted('uploaded')).toEqual([[item]])
|
||||||
|
await vi.waitFor(() => expect(modalOpen()).toBe(false), { timeout: 2000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('chooses nothing when the server refuses the file', async () => {
|
||||||
|
await openField(undefined, libraryRoutes())
|
||||||
|
await clickTab('Upload')
|
||||||
|
await chooseSvg()
|
||||||
|
|
||||||
|
FakeUpload.last!.answer(413, '')
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(wrapper!.emitted('uploaded')).toBeUndefined()
|
||||||
|
expect(wrapper!.emitted('chosen')).toBeUndefined()
|
||||||
|
expect(dialogText()).toContain("can't be used")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { defineComponent, h, ref } from 'vue'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { addCollection } from '@iconify/vue'
|
||||||
|
import lucide from 'virtual:lucide-subset'
|
||||||
|
import { setLocale } from '@/i18n'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import type { MediaImage, MediaSummary } from '@/lib/mediaField'
|
||||||
|
import { drawsIcon, fieldLibrary } from '@/views/__tests__/mediaFieldKit'
|
||||||
|
import MediaFieldControl from '../MediaFieldControl.vue'
|
||||||
|
import { byText, holder, mountInApp, text, unmountApp } from './directoryKit'
|
||||||
|
import { MOBILE_WIDTH } from './viewport'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => null) }))
|
||||||
|
|
||||||
|
addCollection(lucide)
|
||||||
|
|
||||||
|
const LIME: MediaImage = {
|
||||||
|
id: 7,
|
||||||
|
filename: 'lime-plaster-workshop.jpg',
|
||||||
|
width: 1600,
|
||||||
|
height: 900,
|
||||||
|
size: 245760,
|
||||||
|
thumbnail_url: '/media/lime-plaster-workshop.jpg',
|
||||||
|
}
|
||||||
|
const VECTOR: MediaImage = {
|
||||||
|
id: 4,
|
||||||
|
filename: 'logo-renew.svg',
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
size: 8192,
|
||||||
|
thumbnail_url: '/media/logo-renew.svg',
|
||||||
|
}
|
||||||
|
const MISSING: MediaSummary = { id: 7, missing: true }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const items = fieldLibrary()
|
||||||
|
vi.mocked(useApi).mockReturnValue({
|
||||||
|
getMe: vi.fn(),
|
||||||
|
request: vi.fn().mockResolvedValue({
|
||||||
|
items,
|
||||||
|
total: 48,
|
||||||
|
page: 1,
|
||||||
|
size: 25,
|
||||||
|
counts: { all: 48, unused: 2, duplicates: 2 },
|
||||||
|
}),
|
||||||
|
} as unknown as ReturnType<typeof useApi>)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setLocale('en')
|
||||||
|
unmountApp()
|
||||||
|
})
|
||||||
|
|
||||||
|
type Props = Record<string, unknown>
|
||||||
|
|
||||||
|
async function mountControl(props: Props = {}, width?: number) {
|
||||||
|
const wrapper = await mountInApp(
|
||||||
|
MediaFieldControl,
|
||||||
|
{ label: 'Thumbnail', modelValue: null, image: null, ...props },
|
||||||
|
{},
|
||||||
|
width,
|
||||||
|
)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountStateful(value: number | null, image: MediaSummary | null, required = false) {
|
||||||
|
const Host = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const current = ref(value)
|
||||||
|
const shown = ref(image)
|
||||||
|
return () =>
|
||||||
|
h(MediaFieldControl, {
|
||||||
|
label: 'Thumbnail',
|
||||||
|
required,
|
||||||
|
modelValue: current.value,
|
||||||
|
image: shown.value,
|
||||||
|
onChange: (id: number | null, next: MediaSummary | null) => {
|
||||||
|
current.value = id
|
||||||
|
shown.value = next
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return mountInApp(Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
const control = () => document.body.querySelector<HTMLElement>('[data-testid="media-control"]')!
|
||||||
|
const buttons = () => [...control().querySelectorAll<HTMLButtonElement>('button')]
|
||||||
|
const buttonNamed = (name: string) =>
|
||||||
|
buttons().find((button) => (button.getAttribute('aria-label') ?? text(button)) === name)
|
||||||
|
const thumbnail = () =>
|
||||||
|
control().querySelector<HTMLButtonElement>('[data-testid="media-thumbnail"]')
|
||||||
|
const live = () => text(document.body.querySelector('[data-testid="media-live"]'))
|
||||||
|
const dialog = () => document.body.querySelector('[role="dialog"]')
|
||||||
|
const main = () => control().querySelector<HTMLButtonElement>('[data-field-focus]')!
|
||||||
|
|
||||||
|
async function openFromMain() {
|
||||||
|
main().click()
|
||||||
|
await flushPromises()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseInPicker(filename: string) {
|
||||||
|
const tile = [...document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]')].find(
|
||||||
|
(element) => element.getAttribute('aria-label')!.startsWith(`${filename},`),
|
||||||
|
)!
|
||||||
|
tile.click()
|
||||||
|
await flushPromises()
|
||||||
|
byText('button', 'Use this image')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Empty, optional', () => {
|
||||||
|
it('draws the label, a dashed box, the icon square, the text, the button and the help', async () => {
|
||||||
|
await mountControl()
|
||||||
|
|
||||||
|
expect(text(document.body)).toContain('Thumbnail')
|
||||||
|
expect(text(document.body)).not.toContain('Thumbnail *')
|
||||||
|
const box = control()
|
||||||
|
expect(box.className).toContain('border-dashed')
|
||||||
|
expect(box.className).toContain('border-accented')
|
||||||
|
expect(box.className).toContain('p-3')
|
||||||
|
expect(text(box)).toContain('No image selected.')
|
||||||
|
const square = box.querySelector('span.size-10')!
|
||||||
|
expect(square.className).toContain('bg-elevated')
|
||||||
|
expect(drawsIcon(square, 'image')).toBe(true)
|
||||||
|
const pick = buttonNamed('Pick an image for Thumbnail')!
|
||||||
|
expect(text(pick)).toBe('Pick an image')
|
||||||
|
expect(drawsIcon(pick, 'image-plus')).toBe(true)
|
||||||
|
expect(pick.className).toContain('border-accented')
|
||||||
|
expect(text(document.body)).toContain('One image from your library.')
|
||||||
|
expect(thumbnail()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stacks the box and makes the button 44 px high on mobile', async () => {
|
||||||
|
await mountControl({}, MOBILE_WIDTH)
|
||||||
|
|
||||||
|
const pick = buttonNamed('Pick an image for Thumbnail')!
|
||||||
|
expect(pick.className).toContain('h-11')
|
||||||
|
expect(pick.className).not.toContain('flex-1')
|
||||||
|
expect(pick.parentElement!.className).toContain('flex-col')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Empty, required', () => {
|
||||||
|
it('reads Thumbnail * with the asterisk hidden from assistive technology', async () => {
|
||||||
|
await mountControl({ required: true })
|
||||||
|
|
||||||
|
const label = document.body.querySelector('label')!
|
||||||
|
expect(text(label)).toBe('Thumbnail *')
|
||||||
|
expect(label.querySelector('[aria-hidden="true"]')!.textContent).toBe(' *')
|
||||||
|
expect(text(control())).toContain('No image selected.')
|
||||||
|
expect(text(document.body)).toContain('One image from your library.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Filled', () => {
|
||||||
|
it('draws the thumbnail, the filename, the size line, the buttons and the help', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME })
|
||||||
|
|
||||||
|
const box = control()
|
||||||
|
expect(box.className).toContain('border-accented')
|
||||||
|
expect(box.className).not.toContain('border-dashed')
|
||||||
|
const thumb = thumbnail()!
|
||||||
|
expect(thumb.className).toContain('md:h-30')
|
||||||
|
expect(thumb.className).toContain('md:w-40')
|
||||||
|
expect(thumb.querySelector('img')!.getAttribute('src')).toBe(LIME.thumbnail_url)
|
||||||
|
expect(text(box)).toContain('lime-plaster-workshop.jpg')
|
||||||
|
expect(text(box.querySelector('[data-testid="media-meta"]'))).toBe('1600 × 900 · 240 KB')
|
||||||
|
expect(box.querySelector('[data-testid="media-meta"]')!.className).toContain('text-xs/4.5')
|
||||||
|
const change = buttonNamed('Change image')!
|
||||||
|
expect(drawsIcon(change, 'image')).toBe(true)
|
||||||
|
const remove = buttonNamed('Remove image from Thumbnail')!
|
||||||
|
expect(text(remove)).toBe('Remove')
|
||||||
|
expect(drawsIcon(remove, 'x')).toBe(true)
|
||||||
|
expect(text(document.body)).toContain('One image from your library.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads Vector for an image without dimensions', async () => {
|
||||||
|
await mountControl({ modelValue: 4, image: VECTOR })
|
||||||
|
|
||||||
|
expect(text(control().querySelector('[data-testid="media-meta"]'))).toBe('Vector · 8 KB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fills the width at 16:9 with two 44 px buttons on mobile', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME }, MOBILE_WIDTH)
|
||||||
|
|
||||||
|
expect(thumbnail()!.className).toContain('max-md:aspect-video')
|
||||||
|
expect(thumbnail()!.className).toContain('w-full')
|
||||||
|
expect(buttonNamed('Change image')!.className).toContain('h-11')
|
||||||
|
expect(buttonNamed('Remove image from Thumbnail')!.className).toContain('h-11')
|
||||||
|
expect(buttonNamed('Change image')!.className).toContain('max-md:flex-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Hover and focus', () => {
|
||||||
|
it('darkens the border and shows the Change image bar on desktop only', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME })
|
||||||
|
const thumb = thumbnail()!
|
||||||
|
|
||||||
|
thumb.dispatchEvent(new Event('pointerenter'))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(thumb.className).toContain('border-(--ui-text-muted)')
|
||||||
|
const bar = thumb.querySelector('[data-testid="media-hover-bar"]')!
|
||||||
|
expect(text(bar)).toBe('Change image')
|
||||||
|
expect(bar.className).toContain('h-7')
|
||||||
|
expect(bar.className).toContain('bg-inverted')
|
||||||
|
expect(bar.className).toContain('text-inverted')
|
||||||
|
expect(drawsIcon(bar, 'image')).toBe(true)
|
||||||
|
|
||||||
|
thumb.dispatchEvent(new Event('pointerleave'))
|
||||||
|
await flushPromises()
|
||||||
|
expect(thumb.querySelector('[data-testid="media-hover-bar"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no hover state on mobile', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME }, MOBILE_WIDTH)
|
||||||
|
|
||||||
|
thumbnail()!.dispatchEvent(new Event('pointerenter'))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(thumbnail()!.querySelector('[data-testid="media-hover-bar"]')).toBeNull()
|
||||||
|
expect(thumbnail()!.className).not.toContain('border-(--ui-text-muted)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws a 2 px primary outline with a 2 px offset when keyboard focused', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME })
|
||||||
|
|
||||||
|
const classes = thumbnail()!.className
|
||||||
|
expect(classes).toContain('focus-visible:outline-2')
|
||||||
|
expect(classes).toContain('focus-visible:outline-offset-2')
|
||||||
|
expect(classes).toContain('focus-visible:outline-primary')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error', () => {
|
||||||
|
it('draws the error border and line, ties them to the main button and hides the help from sight', async () => {
|
||||||
|
await mountControl({ required: true, error: 'Pick an image for Thumbnail.' })
|
||||||
|
|
||||||
|
expect(control().className).toContain('border-error')
|
||||||
|
expect(control().className).toContain('shadow-[inset_0_0_0_1px_var(--ui-error)]')
|
||||||
|
const alert = document.body.querySelector('[role="alert"]')!
|
||||||
|
expect(text(alert)).toBe('Pick an image for Thumbnail.')
|
||||||
|
expect(drawsIcon(alert, 'circle-alert')).toBe(true)
|
||||||
|
const pick = main()
|
||||||
|
expect(pick.getAttribute('aria-invalid')).toBe('true')
|
||||||
|
const ids = pick.getAttribute('aria-describedby')!.split(' ')
|
||||||
|
expect(ids).toHaveLength(2)
|
||||||
|
expect(document.getElementById(ids[0]!)).toBe(alert)
|
||||||
|
expect(text(document.getElementById(ids[1]!))).toBe('One image from your library.')
|
||||||
|
expect(document.getElementById(ids[1]!)!.className).toContain('sr-only')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no aria-invalid and only the help id without an error', async () => {
|
||||||
|
await mountControl()
|
||||||
|
|
||||||
|
expect(main().getAttribute('aria-invalid')).toBeNull()
|
||||||
|
expect(main().getAttribute('aria-describedby')!.split(' ')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Missing image', () => {
|
||||||
|
it('draws a dashed placeholder, the title, the optional help and the two buttons, with no help line below', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: MISSING })
|
||||||
|
|
||||||
|
const placeholder = control().querySelector('[data-testid="media-placeholder"]')!
|
||||||
|
expect(placeholder.getAttribute('role')).toBe('img')
|
||||||
|
expect(placeholder.getAttribute('aria-label')).toBe('Image no longer in the library')
|
||||||
|
expect(placeholder.className).toContain('border-dashed')
|
||||||
|
expect(drawsIcon(placeholder, 'image-off')).toBe(true)
|
||||||
|
expect(placeholder.querySelector('svg')!.getAttribute('class')).toContain('size-6')
|
||||||
|
expect(text(control())).toContain('This image is no longer in the library')
|
||||||
|
expect(text(control())).toContain(
|
||||||
|
'You can still save. The entry keeps the link until you change it.',
|
||||||
|
)
|
||||||
|
expect(text(buttonNamed('Choose another image'))).toBe('Choose another image')
|
||||||
|
expect(buttonNamed('Clear Thumbnail')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
expect(text(buttonNamed('Clear Thumbnail'))).toBe('Clear')
|
||||||
|
expect(document.body.querySelector('[data-testid="media-help"]')).toBeNull()
|
||||||
|
expect(thumbnail()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the required help and keeps the main button first', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: MISSING, required: true })
|
||||||
|
|
||||||
|
expect(text(control())).toContain('Thumbnail is required. Choose another image to save.')
|
||||||
|
expect(main().textContent).toContain('Choose another image')
|
||||||
|
expect(main().getAttribute('aria-required')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('takes the error border and line when the field is required and saved', async () => {
|
||||||
|
await mountControl({
|
||||||
|
modelValue: 7,
|
||||||
|
image: MISSING,
|
||||||
|
required: true,
|
||||||
|
error: 'Pick an image for Thumbnail.',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(control().className).toContain('border-error')
|
||||||
|
expect(text(document.body.querySelector('[role="alert"]'))).toBe(
|
||||||
|
'Pick an image for Thumbnail.',
|
||||||
|
)
|
||||||
|
expect(document.body.querySelector('[data-testid="media-help"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stacks a 16:9 placeholder and full width buttons on mobile', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: MISSING }, MOBILE_WIDTH)
|
||||||
|
|
||||||
|
expect(control().querySelector('[data-testid="media-placeholder"]')!.className).toContain(
|
||||||
|
'max-md:aspect-video',
|
||||||
|
)
|
||||||
|
expect(buttonNamed('Clear Thumbnail')!.className).toContain('h-11')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Saving and read-only', () => {
|
||||||
|
it('puts the thumbnail at 60 % and disables the buttons, and the picker cannot open', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME, disabled: true })
|
||||||
|
|
||||||
|
expect(thumbnail()!.className).toContain('opacity-60')
|
||||||
|
expect(thumbnail()!.disabled).toBe(true)
|
||||||
|
expect(buttons().every((button) => button.disabled)).toBe(true)
|
||||||
|
expect(buttonNamed('Change image')!.className).toContain('disabled:opacity-40')
|
||||||
|
|
||||||
|
thumbnail()!.click()
|
||||||
|
buttonNamed('Change image')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(dialog()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables the pick button of an empty control', async () => {
|
||||||
|
await mountControl({ disabled: true })
|
||||||
|
|
||||||
|
expect(main().disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts the missing placeholder at 60 %', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: MISSING, disabled: true })
|
||||||
|
|
||||||
|
expect(control().querySelector('[data-testid="media-placeholder"]')!.className).toContain(
|
||||||
|
'opacity-60',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Events', () => {
|
||||||
|
function emitted() {
|
||||||
|
return holder.wrapper!.findComponent(MediaFieldControl).emitted()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('opens the picker in field mode from the thumbnail, Change image and Pick an image', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME })
|
||||||
|
|
||||||
|
thumbnail()!.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(text(dialog())).toContain('Choose an image')
|
||||||
|
byText('button', 'Cancel')!.click()
|
||||||
|
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||||
|
|
||||||
|
buttonNamed('Change image')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(text(dialog())).toContain('Choose an image')
|
||||||
|
expect(emitted()['update:modelValue']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens from Pick an image on an empty control', async () => {
|
||||||
|
await mountControl()
|
||||||
|
|
||||||
|
await openFromMain()
|
||||||
|
|
||||||
|
expect(text(dialog())).toContain('Choose an image')
|
||||||
|
expect(text(dialog())).toContain('No image')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides No image in the picker of a required field', async () => {
|
||||||
|
await mountControl({ required: true })
|
||||||
|
|
||||||
|
await openFromMain()
|
||||||
|
|
||||||
|
expect(text(dialog())).not.toContain('Leave this field empty.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits the id and the library data of the picked image and announces it', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME })
|
||||||
|
await openFromMain()
|
||||||
|
|
||||||
|
await chooseInPicker('bathroom-tiles.png')
|
||||||
|
|
||||||
|
expect(emitted()['update:modelValue']).toEqual([[8]])
|
||||||
|
const [[id, image]] = emitted().change as [[number, MediaImage]]
|
||||||
|
expect(id).toBe(8)
|
||||||
|
expect(image).toEqual({
|
||||||
|
id: 8,
|
||||||
|
filename: 'bathroom-tiles.png',
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
size: 51200,
|
||||||
|
thumbnail_url: '/media/bathroom-tiles.png',
|
||||||
|
})
|
||||||
|
expect(live()).toBe('Image selected: bathroom-tiles.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves the focus to Change image after Use this image', async () => {
|
||||||
|
await mountStateful(null, null)
|
||||||
|
await openFromMain()
|
||||||
|
await chooseInPicker('bathroom-tiles.png')
|
||||||
|
await vi.waitFor(() => expect(document.activeElement).toBe(main()))
|
||||||
|
|
||||||
|
expect(text(main())).toBe('Change image')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not announce or emit when the current image is chosen again', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME })
|
||||||
|
await openFromMain()
|
||||||
|
|
||||||
|
await chooseInPicker('lime-plaster-workshop.jpg')
|
||||||
|
|
||||||
|
expect(emitted()['update:modelValue']).toBeUndefined()
|
||||||
|
expect(emitted().change).toBeUndefined()
|
||||||
|
expect(live()).toBe('')
|
||||||
|
await vi.waitFor(() => expect(document.activeElement).toBe(main()))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('takes a finished upload as the value, announces it and closes the dialog', async () => {
|
||||||
|
await mountControl()
|
||||||
|
await openFromMain()
|
||||||
|
const uploaded = { ...fieldLibrary()[1]!, id: 13, filename: 'diagram.svg' }
|
||||||
|
|
||||||
|
holder.wrapper!.findComponent({ name: 'ImageUploader' }).vm.$emit('uploaded', uploaded)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(emitted()['update:modelValue']).toEqual([[13]])
|
||||||
|
expect(live()).toBe('Image selected: diagram.svg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits null for Remove, announces it and focuses Pick an image', async () => {
|
||||||
|
await mountStateful(7, LIME)
|
||||||
|
|
||||||
|
buttonNamed('Remove image from Thumbnail')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(emitted()['update:modelValue']).toEqual([[null]])
|
||||||
|
expect(emitted().change).toEqual([[null, null]])
|
||||||
|
expect(live()).toBe('Image removed from Thumbnail.')
|
||||||
|
await vi.waitFor(() => expect(document.activeElement).toBe(main()))
|
||||||
|
expect(text(main())).toBe('Pick an image')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits null for Clear, announces Thumbnail cleared and focuses Pick an image', async () => {
|
||||||
|
await mountStateful(7, MISSING)
|
||||||
|
|
||||||
|
buttonNamed('Clear Thumbnail')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(emitted()['update:modelValue']).toEqual([[null]])
|
||||||
|
expect(live()).toBe('Thumbnail cleared.')
|
||||||
|
await vi.waitFor(() => expect(document.activeElement).toBe(main()))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits null from the No image row with Remove image and focuses Pick an image', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME })
|
||||||
|
await openFromMain()
|
||||||
|
|
||||||
|
document.body.querySelector<HTMLButtonElement>('[data-testid="picker-no-image"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
byText('button', 'Remove image')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(emitted()['update:modelValue']).toEqual([[null]])
|
||||||
|
expect(live()).toBe('Image removed from Thumbnail.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the focus to the opener after Cancel and after Escape', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME })
|
||||||
|
const opener = buttonNamed('Change image')!
|
||||||
|
opener.focus()
|
||||||
|
|
||||||
|
opener.click()
|
||||||
|
await flushPromises()
|
||||||
|
byText('button', 'Cancel')!.click()
|
||||||
|
await vi.waitFor(() => expect(document.activeElement).toBe(opener))
|
||||||
|
|
||||||
|
thumbnail()!.focus()
|
||||||
|
thumbnail()!.click()
|
||||||
|
await flushPromises()
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||||
|
await vi.waitFor(() => expect(document.activeElement).toBe(thumbnail()))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('announces nothing when the picker opens', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME })
|
||||||
|
|
||||||
|
await openFromMain()
|
||||||
|
|
||||||
|
expect(live()).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Names and roles', () => {
|
||||||
|
it('is a group labelled by the field label', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME })
|
||||||
|
|
||||||
|
const labelledBy = control().getAttribute('aria-labelledby')!
|
||||||
|
expect(control().getAttribute('role')).toBe('group')
|
||||||
|
expect(text(document.getElementById(labelledBy))).toBe('Thumbnail')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carries the exact accessible names', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME })
|
||||||
|
|
||||||
|
expect(thumbnail()!.getAttribute('aria-label')).toBe(
|
||||||
|
'Change image, lime-plaster-workshop.jpg, 1600 by 900, 240 KB',
|
||||||
|
)
|
||||||
|
expect(buttonNamed('Remove image from Thumbnail')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
expect(buttonNamed('Change image')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names the placeholder, the empty button and the clear button', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: MISSING })
|
||||||
|
expect(control().querySelector('[role="img"]')!.getAttribute('aria-label')).toBe(
|
||||||
|
'Image no longer in the library',
|
||||||
|
)
|
||||||
|
expect(buttonNamed('Clear Thumbnail')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
unmountApp()
|
||||||
|
|
||||||
|
await mountControl()
|
||||||
|
expect(buttonNamed('Pick an image for Thumbnail')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the main button required only when the field is', async () => {
|
||||||
|
await mountControl({ required: true })
|
||||||
|
expect(main().getAttribute('aria-required')).toBe('true')
|
||||||
|
unmountApp()
|
||||||
|
|
||||||
|
await mountControl()
|
||||||
|
expect(main().getAttribute('aria-required')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has two tab stops in order: thumbnail, Change image, Remove', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buttons().map((button) => button.getAttribute('aria-label') ?? text(button)),
|
||||||
|
).toEqual([
|
||||||
|
'Change image, lime-plaster-workshop.jpg, 1600 by 900, 240 KB',
|
||||||
|
'Change image',
|
||||||
|
'Remove image from Thumbnail',
|
||||||
|
])
|
||||||
|
expect(buttons().every((button) => button.tabIndex === 0)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps one polite live region', async () => {
|
||||||
|
await mountControl({ modelValue: 7, image: LIME })
|
||||||
|
|
||||||
|
const regions = document.body.querySelectorAll('[aria-live="polite"][role="status"]')
|
||||||
|
expect(regions).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('French', () => {
|
||||||
|
it('reads the empty, filled and missing states in French', async () => {
|
||||||
|
setLocale('fr')
|
||||||
|
|
||||||
|
await mountControl()
|
||||||
|
expect(text(control())).toContain('Aucune image sélectionnée.')
|
||||||
|
expect(buttonNamed('Choisir une image pour Thumbnail')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
expect(text(document.body)).toContain('Une image de votre bibliothèque.')
|
||||||
|
unmountApp()
|
||||||
|
|
||||||
|
await mountControl({ modelValue: 7, image: LIME })
|
||||||
|
expect(buttonNamed('Changer d’image')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
expect(buttonNamed('Retirer l’image de Thumbnail')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
expect(thumbnail()!.getAttribute('aria-label')).toBe(
|
||||||
|
'Changer d’image, lime-plaster-workshop.jpg, 1600 par 900, 240 Ko',
|
||||||
|
)
|
||||||
|
unmountApp()
|
||||||
|
|
||||||
|
await mountControl({ modelValue: 7, image: MISSING })
|
||||||
|
expect(text(control())).toContain('Cette image n’est plus dans la bibliothèque')
|
||||||
|
expect(buttonNamed('Effacer Thumbnail')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -78,8 +78,12 @@ export const Host = defineComponent({
|
|||||||
props: {
|
props: {
|
||||||
initialFile: { type: Object as () => File | null, default: null },
|
initialFile: { type: Object as () => File | null, default: null },
|
||||||
inArticleIds: { type: Array as () => number[], default: () => [1, 4] },
|
inArticleIds: { type: Array as () => number[], default: () => [1, 4] },
|
||||||
|
field: {
|
||||||
|
type: Object as () => { optional: boolean; currentId: number | null } | undefined,
|
||||||
|
default: undefined,
|
||||||
},
|
},
|
||||||
emits: ['uploaded', 'inserted', 'close'],
|
},
|
||||||
|
emits: ['uploaded', 'inserted', 'close', 'chosen', 'closed'],
|
||||||
setup(_props, { emit }) {
|
setup(_props, { emit }) {
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
return {
|
return {
|
||||||
@@ -88,13 +92,19 @@ export const Host = defineComponent({
|
|||||||
onInserted: (item: MediaItem, alt: string, src: string) =>
|
onInserted: (item: MediaItem, alt: string, src: string) =>
|
||||||
emit('inserted', item, alt, src),
|
emit('inserted', item, alt, src),
|
||||||
onClose: () => emit('close'),
|
onClose: () => emit('close'),
|
||||||
|
onChosen: (item: MediaItem | null) => emit('chosen', item),
|
||||||
|
onClosed: () => emit('closed'),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
template: `<ImageUploader v-model:open="open" :initial-file="initialFile" :in-article-ids="inArticleIds" @uploaded="onUploaded" @inserted="onInserted" @close="onClose" />`,
|
template: `<ImageUploader v-model:open="open" :initial-file="initialFile" :in-article-ids="inArticleIds" :field="field" @uploaded="onUploaded" @inserted="onInserted" @close="onClose" @chosen="onChosen" @closed="onClosed" />`,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function mountUploader(
|
export function mountUploader(
|
||||||
props: { initialFile?: File | null; inArticleIds?: number[] } = {},
|
props: {
|
||||||
|
initialFile?: File | null
|
||||||
|
inArticleIds?: number[]
|
||||||
|
field?: { optional: boolean; currentId: number | null }
|
||||||
|
} = {},
|
||||||
width = DESKTOP_WIDTH,
|
width = DESKTOP_WIDTH,
|
||||||
) {
|
) {
|
||||||
setViewportWidth(width)
|
setViewportWidth(width)
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { effectScope } from 'vue'
|
||||||
|
import { useEntryForm } from '@/composables/useEntryForm'
|
||||||
|
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
||||||
|
import { issueFor } from '@/lib/entryForm'
|
||||||
|
import type { MediaImage } from '@/lib/mediaField'
|
||||||
|
|
||||||
|
const fields: BuiltinField[] = [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
label: 'Title',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
unique: false,
|
||||||
|
default: null,
|
||||||
|
min: null,
|
||||||
|
max: null,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'thumbnail',
|
||||||
|
label: 'Thumbnail',
|
||||||
|
type: 'media',
|
||||||
|
required: false,
|
||||||
|
unique: false,
|
||||||
|
default: null,
|
||||||
|
min: null,
|
||||||
|
max: null,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function image(id: number): MediaImage {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
filename: `image-${id}.jpg`,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
size: 51200,
|
||||||
|
thumbnail_url: '/t.jpg',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function entry(thumbnail: number | null): ContentEntry {
|
||||||
|
return {
|
||||||
|
id: 42,
|
||||||
|
content_type: 'events',
|
||||||
|
data: { title: 'Open day', thumbnail },
|
||||||
|
media: thumbnail === null ? {} : { thumbnail: image(thumbnail) },
|
||||||
|
created_by: 1,
|
||||||
|
updated_by: 1,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(stored: ContentEntry) {
|
||||||
|
const form = effectScope().run(() =>
|
||||||
|
useEntryForm(
|
||||||
|
() => fields,
|
||||||
|
() => false,
|
||||||
|
),
|
||||||
|
)!
|
||||||
|
form.reset(stored)
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dirty tracking of a media value', () => {
|
||||||
|
it('starts clean after reset', () => {
|
||||||
|
expect(build(entry(7)).dirty.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns dirty when another image is picked', () => {
|
||||||
|
const form = build(entry(7))
|
||||||
|
form.setValue('thumbnail', 8, image(8))
|
||||||
|
|
||||||
|
expect(form.dirty.value).toBe(true)
|
||||||
|
expect(form.images.value.thumbnail).toEqual(image(8))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is clean again when 8 is picked then 7', () => {
|
||||||
|
const form = build(entry(7))
|
||||||
|
form.setValue('thumbnail', 8, image(8))
|
||||||
|
form.setValue('thumbnail', 7, image(7))
|
||||||
|
|
||||||
|
expect(form.dirty.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns dirty when the image is removed and clean when it is restored', () => {
|
||||||
|
const form = build(entry(7))
|
||||||
|
form.setValue('thumbnail', null, null)
|
||||||
|
expect(form.dirty.value).toBe(true)
|
||||||
|
expect(form.images.value.thumbnail).toBeUndefined()
|
||||||
|
|
||||||
|
form.setValue('thumbnail', 7, image(7))
|
||||||
|
expect(form.dirty.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is clean again after a reset from the answered entry', () => {
|
||||||
|
const form = build(entry(7))
|
||||||
|
form.setValue('thumbnail', 8, image(8))
|
||||||
|
form.reset(entry(8))
|
||||||
|
|
||||||
|
expect(form.dirty.value).toBe(false)
|
||||||
|
expect(form.images.value.thumbnail).toEqual(image(8))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the thumbnail error when an image is picked', () => {
|
||||||
|
const form = build(entry(null))
|
||||||
|
form.rejectField('thumbnail', issueFor(fields[1], 'required'))
|
||||||
|
expect(form.hasIssues.value).toBe(true)
|
||||||
|
|
||||||
|
form.setValue('thumbnail', 8, image(8))
|
||||||
|
expect(form.hasIssues.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,6 +5,7 @@ import { useEntries } from '@/composables/useEntries'
|
|||||||
import { useEntryForm } from '@/composables/useEntryForm'
|
import { useEntryForm } from '@/composables/useEntryForm'
|
||||||
import { useEntryLoad } from '@/composables/useEntryLoad'
|
import { useEntryLoad } from '@/composables/useEntryLoad'
|
||||||
import type { ContentEntry } from '@/lib/contentTypes'
|
import type { ContentEntry } from '@/lib/contentTypes'
|
||||||
|
import type { MediaSummary } from '@/lib/mediaField'
|
||||||
import { resolveEditorPhase, type EntryTarget } from '@/lib/entryEditorState'
|
import { resolveEditorPhase, type EntryTarget } from '@/lib/entryEditorState'
|
||||||
import { buildData, issueFor, validateEntry } from '@/lib/entryForm'
|
import { buildData, issueFor, validateEntry } from '@/lib/entryForm'
|
||||||
import { classifyRefusal, isGoneError, parseEntryRefusal } from '@/lib/entryServerError'
|
import { classifyRefusal, isGoneError, parseEntryRefusal } from '@/lib/entryServerError'
|
||||||
@@ -91,7 +92,7 @@ export function useEntryDraft(typeKey: string, target: EntryTarget) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
failure.value = 'none'
|
failure.value = 'none'
|
||||||
const issues = validateEntry(fields.value, form.values.value)
|
const issues = validateEntry(fields.value, form.values.value, form.images.value)
|
||||||
if (Object.keys(issues).length > 0) {
|
if (Object.keys(issues).length > 0) {
|
||||||
form.reject(issues)
|
form.reject(issues)
|
||||||
focusToken.value++
|
focusToken.value++
|
||||||
@@ -113,8 +114,8 @@ export function useEntryDraft(typeKey: string, target: EntryTarget) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function change(key: string, value: unknown) {
|
function change(key: string, value: unknown, image?: MediaSummary | null) {
|
||||||
form.setValue(key, value)
|
form.setValue(key, value, image)
|
||||||
}
|
}
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
||||||
|
import type { MediaMap, MediaSummary } from '@/lib/mediaField'
|
||||||
import {
|
import {
|
||||||
carriesDefault,
|
carriesDefault,
|
||||||
firstIssueKey,
|
firstIssueKey,
|
||||||
|
initialImages,
|
||||||
initialValues,
|
initialValues,
|
||||||
valuesDiffer,
|
valuesDiffer,
|
||||||
type EntryIssues,
|
type EntryIssues,
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
export function useEntryForm(fields: () => BuiltinField[], creating: () => boolean) {
|
export function useEntryForm(fields: () => BuiltinField[], creating: () => boolean) {
|
||||||
const values = ref<EntryValues>({})
|
const values = ref<EntryValues>({})
|
||||||
const saved = ref<EntryValues>({})
|
const saved = ref<EntryValues>({})
|
||||||
|
const images = ref<MediaMap>({})
|
||||||
const issues = ref<EntryIssues>({})
|
const issues = ref<EntryIssues>({})
|
||||||
const rejected = ref(false)
|
const rejected = ref(false)
|
||||||
|
|
||||||
@@ -24,12 +27,27 @@ export function useEntryForm(fields: () => BuiltinField[], creating: () => boole
|
|||||||
const next = initialValues(fields(), entry)
|
const next = initialValues(fields(), entry)
|
||||||
values.value = { ...next }
|
values.value = { ...next }
|
||||||
saved.value = { ...next }
|
saved.value = { ...next }
|
||||||
|
images.value = initialImages(fields(), entry)
|
||||||
issues.value = {}
|
issues.value = {}
|
||||||
rejected.value = false
|
rejected.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function setValue(key: string, value: unknown) {
|
function setImage(key: string, image: MediaSummary | null | undefined) {
|
||||||
|
if (image === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = { ...images.value }
|
||||||
|
if (image === null) {
|
||||||
|
delete next[key]
|
||||||
|
} else {
|
||||||
|
next[key] = image
|
||||||
|
}
|
||||||
|
images.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function setValue(key: string, value: unknown, image?: MediaSummary | null) {
|
||||||
values.value = { ...values.value, [key]: value }
|
values.value = { ...values.value, [key]: value }
|
||||||
|
setImage(key, image)
|
||||||
rejected.value = false
|
rejected.value = false
|
||||||
if (issues.value[key]) {
|
if (issues.value[key]) {
|
||||||
const rest = { ...issues.value }
|
const rest = { ...issues.value }
|
||||||
@@ -53,6 +71,7 @@ export function useEntryForm(fields: () => BuiltinField[], creating: () => boole
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
values,
|
values,
|
||||||
|
images,
|
||||||
issues,
|
issues,
|
||||||
rejected,
|
rejected,
|
||||||
dirty,
|
dirty,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function useSchemaBuilder(source?: EditSource) {
|
|||||||
const baselineFields = computed(() => (baseline.value ? baselinePayload(baseline.value) : []))
|
const baselineFields = computed(() => (baseline.value ? baselinePayload(baseline.value) : []))
|
||||||
const baselineKeys = computed(() => baselineFields.value.map((field) => field.key))
|
const baselineKeys = computed(() => baselineFields.value.map((field) => field.key))
|
||||||
const savedSignatures = computed(() => baselineSignatures(baselineFields.value))
|
const savedSignatures = computed(() => baselineSignatures(baselineFields.value))
|
||||||
const changes = computed(() => diffFields(baselineFields.value, form.value.fields))
|
const changes = computed(() => diffFields(baselineFields.value, form.value.fields, entryTotal))
|
||||||
const needsReview = computed(() => editing && entryTotal > 0 && changes.value.length > 0)
|
const needsReview = computed(() => editing && entryTotal > 0 && changes.value.length > 0)
|
||||||
const {
|
const {
|
||||||
saveState,
|
saveState,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ApiError } from '@/composables/useApi'
|
|||||||
import { useContentTypes, type ContentType } from '@/composables/useContentTypes'
|
import { useContentTypes, type ContentType } from '@/composables/useContentTypes'
|
||||||
import { SCHEMA_PATH } from '@/lib/routePaths'
|
import { SCHEMA_PATH } from '@/lib/routePaths'
|
||||||
import type { BuilderForm } from '@/lib/schemaBuilderModel'
|
import type { BuilderForm } from '@/lib/schemaBuilderModel'
|
||||||
import { flaggedChanges, type FieldChange } from '@/lib/schemaBuilderDiff'
|
import { flaggedChanges, hasRefusedChange, type FieldChange } from '@/lib/schemaBuilderDiff'
|
||||||
import { formFromType } from '@/lib/schemaBuilderLoad'
|
import { formFromType } from '@/lib/schemaBuilderLoad'
|
||||||
import { buildPayload, buildUpdatePayload } from '@/lib/schemaBuilderPayload'
|
import { buildPayload, buildUpdatePayload } from '@/lib/schemaBuilderPayload'
|
||||||
import type { TypeTouch } from '@/lib/schemaBuilderTouch'
|
import type { TypeTouch } from '@/lib/schemaBuilderTouch'
|
||||||
@@ -210,7 +210,7 @@ export function useSchemaSave(context: SchemaSaveContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function apply() {
|
async function apply() {
|
||||||
if (saving.value) {
|
if (saving.value || (editing && hasRefusedChange(changes.value))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
failure.value = 'none'
|
failure.value = 'none'
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ const SOURCES = [
|
|||||||
'components/EntryForm.vue',
|
'components/EntryForm.vue',
|
||||||
'components/EntryNotices.vue',
|
'components/EntryNotices.vue',
|
||||||
'components/MarkdownField.vue',
|
'components/MarkdownField.vue',
|
||||||
|
'components/MediaFieldControl.vue',
|
||||||
'composables/useEntryEditorPage.ts',
|
'composables/useEntryEditorPage.ts',
|
||||||
'composables/useEntryRemoval.ts',
|
'composables/useEntryRemoval.ts',
|
||||||
'lib/entryForm.ts',
|
'lib/entryForm.ts',
|
||||||
'lib/entryMessages.ts',
|
'lib/entryMessages.ts',
|
||||||
|
'lib/mediaField.ts',
|
||||||
'lib/entryEditors.ts',
|
'lib/entryEditors.ts',
|
||||||
'views/EntryEditorPage.vue',
|
'views/EntryEditorPage.vue',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { messages } from './messages'
|
||||||
|
|
||||||
|
const SOURCES = [
|
||||||
|
'components/ImageUploader.vue',
|
||||||
|
'components/MediaFieldControl.vue',
|
||||||
|
'components/MediaPicker.vue',
|
||||||
|
'components/SchemaChangeRow.vue',
|
||||||
|
'components/SchemaReviewDialog.vue',
|
||||||
|
'components/FieldCell.vue',
|
||||||
|
'components/EntryNotices.vue',
|
||||||
|
'lib/fieldTypes.ts',
|
||||||
|
'lib/mediaField.ts',
|
||||||
|
'lib/schemaBuilderDiff.ts',
|
||||||
|
]
|
||||||
|
|
||||||
|
const SAME_IN_FRENCH = new Set<string>(['Date'])
|
||||||
|
|
||||||
|
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
|
||||||
|
|
||||||
|
function keysIn(source: string) {
|
||||||
|
return [...source.matchAll(LITERAL)].map((match) =>
|
||||||
|
(match[1] ?? match[2] ?? '').replace(/\\u0022/g, '"').replace(/\\(['"\\])/g, '$1'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Media field messages', () => {
|
||||||
|
const keys = SOURCES.flatMap((file) =>
|
||||||
|
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
|
||||||
|
file,
|
||||||
|
key,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
it('finds the strings of the feature', () => {
|
||||||
|
expect(keys.length).toBeGreaterThan(60)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(keys.map(({ file, key }) => [file, key]))(
|
||||||
|
'%s: "%s" has its own French entry',
|
||||||
|
(_file, key) => {
|
||||||
|
const french = (messages as Record<string, string>)[key as string]
|
||||||
|
|
||||||
|
expect(french).toMatch(/\S/)
|
||||||
|
if (!SAME_IN_FRENCH.has(key as string)) {
|
||||||
|
expect(french).not.toBe(key)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -2609,6 +2609,45 @@ export const messages = {
|
|||||||
Heading: 'Titre',
|
Heading: 'Titre',
|
||||||
List: 'Liste',
|
List: 'Liste',
|
||||||
id: 'identifiant',
|
id: 'identifiant',
|
||||||
|
'Image no longer in the library': 'Image absente de la bibliothèque',
|
||||||
|
'Use this image': 'Utiliser cette image',
|
||||||
|
'Choose another image': 'Choisir une autre image',
|
||||||
|
'Clear {0}': 'Effacer {0}',
|
||||||
|
'Image removed from {0}.': 'Image retirée de {0}.',
|
||||||
|
'Image selected: {0}': 'Image sélectionnée : {0}',
|
||||||
|
'No image selected.': 'Aucune image sélectionnée.',
|
||||||
|
'One image from your library.': 'Une image de votre bibliothèque.',
|
||||||
|
'Pick an image': 'Choisir une image',
|
||||||
|
'Pick an image for {0}': 'Choisir une image pour {0}',
|
||||||
|
'Remove image from {0}': 'Retirer l’image de {0}',
|
||||||
|
'This image is no longer in the library': 'Cette image n’est plus dans la bibliothèque',
|
||||||
|
'You can still save. The entry keeps the link until you change it.':
|
||||||
|
'Vous pouvez quand même enregistrer. L’entrée garde le lien tant que vous ne le changez pas.',
|
||||||
|
'{0} cleared.': '{0} effacé.',
|
||||||
|
'{0} is required. Choose another image to save.':
|
||||||
|
'{0} est obligatoire. Choisissez une autre image pour enregistrer.',
|
||||||
|
Current: 'Actuelle',
|
||||||
|
'Images you upload appear here, ready to use in any article or entry.':
|
||||||
|
'Les images que vous importez apparaissent ici, prêtes à servir dans n’importe quel article ou entrée.',
|
||||||
|
'Leave this field empty.': 'Laisser ce champ vide.',
|
||||||
|
"Can't be applied": 'Ne peut pas être appliqué',
|
||||||
|
'Back to editing keeps your other changes.':
|
||||||
|
'Revenir à la modification conserve vos autres changements.',
|
||||||
|
'Pick an image for {0}.': 'Choisissez une image pour {0}.',
|
||||||
|
"The {0} field was changed after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.":
|
||||||
|
'Le champ {0} a été modifié après l’ouverture de ce formulaire. Rechargez le formulaire pour continuer. Ce que vous avez saisi ici ne peut pas être enregistré avec les anciens champs.',
|
||||||
|
'{0} must be an image from the library.': '{0} doit être une image de la bibliothèque.',
|
||||||
|
'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.':
|
||||||
|
'Contient une image de la médiathèque. Les entrées pointent vers l’image au lieu d’en garder une copie, donc elles affichent toujours sa version actuelle.',
|
||||||
|
'One image from your media library': 'Une image de votre médiathèque',
|
||||||
|
'Change image, {0}, {1}, {2}': 'Changer d’image, {0}, {1}, {2}',
|
||||||
|
'{0} by {1}': '{0} par {1}',
|
||||||
|
'1 entry is unaffected.': '1 entrée n’est pas concernée.',
|
||||||
|
"A media field can't have a default, so the existing entry would have no image. Make {0} optional to add it.":
|
||||||
|
'Un champ média ne peut pas avoir de valeur par défaut, donc l’entrée existante n’aurait pas d’image. Rendez {0} facultatif pour l’ajouter.',
|
||||||
|
"A media field can't have a default, so the {0} existing entries would have no image. Make {1} optional to add it.":
|
||||||
|
'Un champ média ne peut pas avoir de valeur par défaut, donc les {0} entrées existantes n’auraient pas d’image. Rendez {1} facultatif pour l’ajouter.',
|
||||||
|
'{0} entries are unaffected.': '{0} entrées ne sont pas concernées.',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type MessageKey = keyof typeof messages
|
export type MessageKey = keyof typeof messages
|
||||||
@@ -9,6 +9,7 @@ const CONTROLS: Record<FieldType, string> = {
|
|||||||
boolean: 'switch',
|
boolean: 'switch',
|
||||||
date: 'date',
|
date: 'date',
|
||||||
select: 'select',
|
select: 'select',
|
||||||
|
media: 'media',
|
||||||
}
|
}
|
||||||
|
|
||||||
function editor(type: FieldType) {
|
function editor(type: FieldType) {
|
||||||
|
|||||||
@@ -0,0 +1,538 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { ContentType } from '@/composables/useContentTypes'
|
||||||
|
import type { MediaItem } from '@/composables/useMedia'
|
||||||
|
import type { ContentEntry } from '@/lib/contentTypes'
|
||||||
|
import { cardFields, describeCell, plainCellText, thumbnailColumn } from '@/lib/entryCells'
|
||||||
|
import { ENTRY_EDITORS } from '@/lib/entryEditors'
|
||||||
|
import { buildData, entryTitle, initialValues, validateEntry } from '@/lib/entryForm'
|
||||||
|
import { ruleMessage } from '@/lib/entryMessages'
|
||||||
|
import { groupFields } from '@/lib/entryLayout'
|
||||||
|
import { ApiError } from '@/lib/apiError'
|
||||||
|
import { classifyRefusal, parseEntryRefusal } from '@/lib/entryServerError'
|
||||||
|
import { FIELD_TYPES, fieldTypeSpec } from '@/lib/fieldTypes'
|
||||||
|
import {
|
||||||
|
imageButtonName,
|
||||||
|
imageMeta,
|
||||||
|
imageOfItem,
|
||||||
|
isMediaId,
|
||||||
|
isMissingImage,
|
||||||
|
mediaOf,
|
||||||
|
} from '@/lib/mediaField'
|
||||||
|
import { diffFields, summaryLine } from '@/lib/schemaBuilderDiff'
|
||||||
|
import { baselinePayload, fieldFromApi, formFromType } from '@/lib/schemaBuilderLoad'
|
||||||
|
import { changeFieldType, createField } from '@/lib/schemaBuilderModel'
|
||||||
|
import { buildPayload, payloadField } from '@/lib/schemaBuilderPayload'
|
||||||
|
import { sampleEntry } from '@/lib/schemaBuilderSample'
|
||||||
|
import { apiField, eventsFields } from './schemaFixtures'
|
||||||
|
|
||||||
|
const LIME = {
|
||||||
|
id: 7,
|
||||||
|
filename: 'lime-plaster-workshop.jpg',
|
||||||
|
width: 1600,
|
||||||
|
height: 900,
|
||||||
|
size: 245760,
|
||||||
|
thumbnail_url: '/media/lime-plaster-workshop.jpg',
|
||||||
|
}
|
||||||
|
const VECTOR = {
|
||||||
|
id: 4,
|
||||||
|
filename: 'logo-renew.svg',
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
size: 8192,
|
||||||
|
thumbnail_url: '/media/logo-renew.svg',
|
||||||
|
}
|
||||||
|
const MISSING = { id: 7, missing: true as const }
|
||||||
|
|
||||||
|
function mediaField(rest: object = {}) {
|
||||||
|
return apiField('Thumbnail', 'thumbnail', 'media', { options: [], ...rest })
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = ENTRY_EDITORS.media
|
||||||
|
|
||||||
|
describe('media in the registry', () => {
|
||||||
|
it('is the seventh type, after select', () => {
|
||||||
|
expect(FIELD_TYPES).toHaveLength(7)
|
||||||
|
expect(FIELD_TYPES[6]).toBe('media')
|
||||||
|
expect(FIELD_TYPES[5]).toBe('select')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('describes itself', () => {
|
||||||
|
const spec = fieldTypeSpec('media')
|
||||||
|
|
||||||
|
expect(spec.label()).toBe('Media')
|
||||||
|
expect(spec.description()).toBe('One image from your media library')
|
||||||
|
expect(spec.icon).toBe('i-lucide-image')
|
||||||
|
expect(spec.editor.control).toBe('media')
|
||||||
|
expect(spec.cell).toBe('thumbnail')
|
||||||
|
expect(spec.narrowColumn).toBe(true)
|
||||||
|
expect(spec.alignEnd).toBe(false)
|
||||||
|
expect(spec.pairs).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports no unique, limits, default or choices', () => {
|
||||||
|
const spec = fieldTypeSpec('media')
|
||||||
|
|
||||||
|
expect(spec.supportsUnique).toBe(false)
|
||||||
|
expect(spec.supportsLimits).toBe(false)
|
||||||
|
expect(spec.supportsDefault).toBe(false)
|
||||||
|
expect(spec.supportsChoices).toBe(false)
|
||||||
|
expect(spec.showsUnsupportedOptions).toBe(false)
|
||||||
|
expect(spec.builderHint?.()).toBe(
|
||||||
|
'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(FIELD_TYPES.filter((type) => type !== 'media'))(
|
||||||
|
'%s keeps its builder options and has no hint',
|
||||||
|
(type) => {
|
||||||
|
const spec = fieldTypeSpec(type)
|
||||||
|
|
||||||
|
expect(spec.supportsDefault).toBe(true)
|
||||||
|
expect(spec.showsUnsupportedOptions).toBe(true)
|
||||||
|
expect(spec.builderHint).toBeNull()
|
||||||
|
expect(spec.supportsChoices).toBe(type === 'select')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('is not the text fallback for a loaded field', () => {
|
||||||
|
expect(fieldTypeSpec('media')).not.toBe(fieldTypeSpec('text'))
|
||||||
|
const field = fieldFromApi(mediaField(), 1)
|
||||||
|
|
||||||
|
expect(field.type).toBe('media')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts empty on create and keeps the stored id on edit', () => {
|
||||||
|
const field = mediaField()
|
||||||
|
|
||||||
|
expect(media.initial(field, undefined, true)).toBeNull()
|
||||||
|
expect(media.initial(mediaField({ default: 7 }), 3, true)).toBeNull()
|
||||||
|
expect(media.initial(field, 7, false)).toBe(7)
|
||||||
|
expect(media.initial(field, null, false)).toBeNull()
|
||||||
|
expect(media.initial(field, undefined, false)).toBeNull()
|
||||||
|
expect(media.initial(field, 'x', false)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has one help line and no counter', () => {
|
||||||
|
expect(media.help(mediaField())).toBe('One image from your library.')
|
||||||
|
expect(media.counter(mediaField(), 7)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('client validation of a media value', () => {
|
||||||
|
it.each([
|
||||||
|
['required', null, 'required'],
|
||||||
|
['required', undefined, 'required'],
|
||||||
|
['optional', null, null],
|
||||||
|
['optional', 7, null],
|
||||||
|
['required', 7, null],
|
||||||
|
['optional', 0, 'type'],
|
||||||
|
['optional', -1, 'type'],
|
||||||
|
['optional', 7.5, 'type'],
|
||||||
|
['optional', '7', 'type'],
|
||||||
|
['optional', true, 'type'],
|
||||||
|
['optional', 4294967296, 'type'],
|
||||||
|
['optional', 4294967295, null],
|
||||||
|
] as const)('%s holding %j gives %s', (required, value, rule) => {
|
||||||
|
const field = mediaField({ required: required === 'required' })
|
||||||
|
|
||||||
|
expect(media.validate(field, value)).toBe(rule)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads a required field whose image is gone as required', () => {
|
||||||
|
const field = mediaField({ required: true })
|
||||||
|
|
||||||
|
expect(media.validate(field, 7, { image: MISSING })).toBe('required')
|
||||||
|
expect(media.validate(field, 7, { image: LIME })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets an optional field keep a missing image', () => {
|
||||||
|
expect(media.validate(mediaField(), 7, { image: MISSING })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hands the image of each field to the validation of the form', () => {
|
||||||
|
const field = mediaField({ required: true })
|
||||||
|
|
||||||
|
expect(validateEntry([field], { thumbnail: 7 }, { thumbnail: MISSING })).toMatchObject({
|
||||||
|
thumbnail: { rule: 'required', message: 'Pick an image for Thumbnail.' },
|
||||||
|
})
|
||||||
|
expect(validateEntry([field], { thumbnail: 7 }, { thumbnail: LIME })).toEqual({})
|
||||||
|
expect(validateEntry([field], { thumbnail: null })).toMatchObject({
|
||||||
|
thumbnail: { rule: 'required' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('messages of a media field', () => {
|
||||||
|
const field = mediaField()
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['required', 'Pick an image for Thumbnail.'],
|
||||||
|
['type', 'Thumbnail must be an image from the library.'],
|
||||||
|
] as const)('the rule %s reads "%s"', (rule, message) => {
|
||||||
|
expect(ruleMessage(rule, field)).toBe(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['invalid_entry: thumbnail: required', 'required'],
|
||||||
|
['invalid_entry: thumbnail: type', 'type'],
|
||||||
|
])('maps the server answer %s to the field', (body, rule) => {
|
||||||
|
const refusal = parseEntryRefusal(new ApiError(422, undefined, body))
|
||||||
|
|
||||||
|
expect(classifyRefusal([field], refusal)).toMatchObject({ kind: 'field', field, rule })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps an unknown media key to a schema change', () => {
|
||||||
|
const refusal = parseEntryRefusal(
|
||||||
|
new ApiError(422, undefined, 'invalid_entry: thumbnail: unknown_field'),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(classifyRefusal([field], refusal)).toEqual({ kind: 'schema', key: 'thumbnail' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('payload of a media value', () => {
|
||||||
|
it.each([
|
||||||
|
[7, 7],
|
||||||
|
[null, null],
|
||||||
|
[undefined, null],
|
||||||
|
] as const)('sends %j as %j', (value, sent) => {
|
||||||
|
expect(media.payload(mediaField(), value)).toBe(sent)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the stored id of a missing image and never drops the key', () => {
|
||||||
|
const fields = [apiField('Title', 'title', 'text'), mediaField()]
|
||||||
|
const entry = {
|
||||||
|
id: 1,
|
||||||
|
content_type: 'events',
|
||||||
|
data: { title: 'x', thumbnail: 7 },
|
||||||
|
media: { thumbnail: MISSING },
|
||||||
|
} as unknown as ContentEntry
|
||||||
|
const values = initialValues(fields, entry)
|
||||||
|
|
||||||
|
expect(buildData(fields, values)).toEqual({ title: 'x', thumbnail: 7 })
|
||||||
|
expect(buildData(fields, initialValues(fields, null))).toEqual({
|
||||||
|
title: null,
|
||||||
|
thumbnail: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the picked id as a JSON number', () => {
|
||||||
|
const sent = JSON.stringify(buildData([mediaField()], { thumbnail: 8 }))
|
||||||
|
|
||||||
|
expect(sent).toBe('{"thumbnail":8}')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('media ids', () => {
|
||||||
|
it.each([
|
||||||
|
[1, true],
|
||||||
|
[4294967295, true],
|
||||||
|
[0, false],
|
||||||
|
[-1, false],
|
||||||
|
[7.5, false],
|
||||||
|
['7', false],
|
||||||
|
[null, false],
|
||||||
|
[4294967296, false],
|
||||||
|
])('%j is an id: %s', (value, expected) => {
|
||||||
|
expect(isMediaId(value)).toBe(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('media summaries', () => {
|
||||||
|
it('reads the map of an entry by field key', () => {
|
||||||
|
const entry = { media: { thumbnail: LIME, cover: MISSING } } as unknown as ContentEntry
|
||||||
|
|
||||||
|
expect(mediaOf(entry, 'thumbnail')).toEqual(LIME)
|
||||||
|
expect(isMissingImage(mediaOf(entry, 'cover')!)).toBe(true)
|
||||||
|
expect(mediaOf(entry, 'absent')).toBeNull()
|
||||||
|
expect(mediaOf({} as ContentEntry, 'thumbnail')).toBeNull()
|
||||||
|
expect(mediaOf(null, 'thumbnail')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('describes an image', () => {
|
||||||
|
expect(imageMeta(LIME)).toBe('1600 × 900 · 240 KB')
|
||||||
|
expect(imageMeta(VECTOR)).toBe('Vector · 8 KB')
|
||||||
|
expect(imageButtonName(LIME)).toBe(
|
||||||
|
'Change image, lime-plaster-workshop.jpg, 1600 by 900, 240 KB',
|
||||||
|
)
|
||||||
|
expect(imageButtonName(VECTOR)).toBe('Change image, logo-renew.svg, Vector, 8 KB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds a summary from a library item without a request', () => {
|
||||||
|
const item = {
|
||||||
|
id: 8,
|
||||||
|
filename: 'bathroom-tiles.png',
|
||||||
|
url: '/media/bathroom-tiles.png',
|
||||||
|
size: 51200,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
} as MediaItem
|
||||||
|
|
||||||
|
expect(imageOfItem(item)).toEqual({
|
||||||
|
id: 8,
|
||||||
|
filename: 'bathroom-tiles.png',
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
size: 51200,
|
||||||
|
thumbnail_url: '/media/bathroom-tiles.png',
|
||||||
|
})
|
||||||
|
expect(imageMeta(imageOfItem(item))).toBe('800 × 600 · 50 KB')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('media in the list', () => {
|
||||||
|
const field = mediaField()
|
||||||
|
|
||||||
|
it('describes a cell from the item own media object', () => {
|
||||||
|
expect(describeCell(field, 7, LIME)).toEqual({
|
||||||
|
kind: 'thumbnail',
|
||||||
|
text: LIME.filename,
|
||||||
|
image: LIME,
|
||||||
|
})
|
||||||
|
expect(describeCell(field, 7, MISSING)).toEqual({
|
||||||
|
kind: 'thumbnail',
|
||||||
|
text: '',
|
||||||
|
image: MISSING,
|
||||||
|
})
|
||||||
|
expect(describeCell(field, 7)).toEqual({ kind: 'thumbnail', text: '', image: MISSING })
|
||||||
|
expect(describeCell(field, null).kind).toBe('empty')
|
||||||
|
expect(describeCell(field, undefined).kind).toBe('empty')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads a plain text of dash for none and never the id', () => {
|
||||||
|
expect(plainCellText(describeCell(field, null))).toBe('–')
|
||||||
|
expect(plainCellText(describeCell(field, 7, MISSING))).toBe('–')
|
||||||
|
expect(plainCellText(describeCell(field, 7, MISSING))).not.toContain('7')
|
||||||
|
expect(plainCellText(describeCell(field, 7, LIME))).toBe(LIME.filename)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('picks a leading thumbnail and keeps the card text fields', () => {
|
||||||
|
const title = apiField('Title', 'title', 'text')
|
||||||
|
const starts = apiField('Starts on', 'starts_on', 'date')
|
||||||
|
const category = apiField('Category', 'category', 'select')
|
||||||
|
|
||||||
|
expect(cardFields([title, field, starts, category])).toEqual({
|
||||||
|
first: title,
|
||||||
|
second: starts,
|
||||||
|
choice: category,
|
||||||
|
})
|
||||||
|
expect(cardFields([field, starts])).toMatchObject({ first: field, second: starts })
|
||||||
|
expect(thumbnailColumn([title, field, starts])).toBe(field)
|
||||||
|
expect(thumbnailColumn([title, starts])).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('titles and layout', () => {
|
||||||
|
const entry = {
|
||||||
|
id: 42,
|
||||||
|
content_type: 'events',
|
||||||
|
data: { thumbnail: 7 },
|
||||||
|
media: { thumbnail: LIME },
|
||||||
|
} as unknown as ContentEntry
|
||||||
|
|
||||||
|
it('never uses a media value as the title of an entry', () => {
|
||||||
|
expect(entryTitle([mediaField()], entry)).toBe('Entry 42')
|
||||||
|
expect(entryTitle([mediaField(), apiField('Title', 'title', 'text')], entry)).toBe(
|
||||||
|
'Entry 42',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never pairs a media field with a neighbour', () => {
|
||||||
|
const flag = apiField('Online', 'online', 'boolean')
|
||||||
|
const number = apiField('Capacity', 'capacity', 'number')
|
||||||
|
const date = apiField('Starts on', 'starts_on', 'date')
|
||||||
|
|
||||||
|
for (const before of [flag, number, date]) {
|
||||||
|
const rows = groupFields([before, mediaField(), apiField('Ends on', 'ends_on', 'date')])
|
||||||
|
|
||||||
|
expect(rows.map((row) => row.length)).toEqual([1, 1, 1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('media in the schema builder', () => {
|
||||||
|
const events: ContentType = {
|
||||||
|
id: 1,
|
||||||
|
key: 'events',
|
||||||
|
name: 'Events',
|
||||||
|
icon: null,
|
||||||
|
fields: [...eventsFields, mediaField()],
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sends the exact body of a media field', () => {
|
||||||
|
const field = createField(1, { label: 'Thumbnail', key: 'thumbnail', type: 'media' })
|
||||||
|
|
||||||
|
expect(payloadField(field)).toEqual({
|
||||||
|
key: 'thumbnail',
|
||||||
|
label: 'Thumbnail',
|
||||||
|
type: 'media',
|
||||||
|
required: false,
|
||||||
|
unique: false,
|
||||||
|
default: null,
|
||||||
|
min: null,
|
||||||
|
max: null,
|
||||||
|
options: [],
|
||||||
|
})
|
||||||
|
expect(payloadField({ ...field, required: true }).required).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores stale settings of another type when sending', () => {
|
||||||
|
const stale = createField(1, {
|
||||||
|
label: 'Thumbnail',
|
||||||
|
key: 'thumbnail',
|
||||||
|
type: 'media',
|
||||||
|
unique: true,
|
||||||
|
defaultValue: 'x',
|
||||||
|
min: '3',
|
||||||
|
max: '9',
|
||||||
|
options: ['a'],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(payloadField(stale)).toMatchObject({
|
||||||
|
unique: false,
|
||||||
|
default: null,
|
||||||
|
min: null,
|
||||||
|
max: null,
|
||||||
|
options: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips a loaded media field with no change', () => {
|
||||||
|
let next = 1
|
||||||
|
const form = formFromType(events, () => next++)
|
||||||
|
const baseline = baselinePayload(events)
|
||||||
|
|
||||||
|
expect(form.fields[7]!.type).toBe('media')
|
||||||
|
expect(buildPayload(form).fields).toEqual(baseline)
|
||||||
|
expect(diffFields(baseline, form.fields, 37)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['text', 'number', 'select', 'richtext'] as const)(
|
||||||
|
'resets unique, default, limits and options changing a new %s row to media and back',
|
||||||
|
(from) => {
|
||||||
|
const row = createField(1, {
|
||||||
|
label: 'Thumbnail',
|
||||||
|
key: 'thumbnail',
|
||||||
|
required: true,
|
||||||
|
type: from,
|
||||||
|
unique: true,
|
||||||
|
defaultValue: 'x',
|
||||||
|
min: '1',
|
||||||
|
max: '80',
|
||||||
|
options: ['a', 'b'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const toMedia = changeFieldType(row, 'media')
|
||||||
|
|
||||||
|
expect(toMedia).toMatchObject({
|
||||||
|
label: 'Thumbnail',
|
||||||
|
key: 'thumbnail',
|
||||||
|
required: true,
|
||||||
|
type: 'media',
|
||||||
|
unique: false,
|
||||||
|
defaultValue: '',
|
||||||
|
min: '',
|
||||||
|
max: '',
|
||||||
|
options: [],
|
||||||
|
})
|
||||||
|
expect(changeFieldType(toMedia, 'number')).toMatchObject({
|
||||||
|
type: 'number',
|
||||||
|
min: '',
|
||||||
|
max: '',
|
||||||
|
defaultValue: '',
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('previews a media value as null', () => {
|
||||||
|
const field = createField(1, { label: 'Thumbnail', key: 'thumbnail', type: 'media' })
|
||||||
|
|
||||||
|
expect(sampleEntry('events', [field]).data.thumbnail).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('review of a media field', () => {
|
||||||
|
const events: ContentType = {
|
||||||
|
id: 1,
|
||||||
|
key: 'events',
|
||||||
|
name: 'Events',
|
||||||
|
icon: null,
|
||||||
|
fields: eventsFields,
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
}
|
||||||
|
const baseline = baselinePayload(events)
|
||||||
|
|
||||||
|
function loaded() {
|
||||||
|
let next = 1
|
||||||
|
return formFromType(events, () => next++).fields
|
||||||
|
}
|
||||||
|
|
||||||
|
function withThumbnail(rest: object = {}) {
|
||||||
|
return [
|
||||||
|
...loaded(),
|
||||||
|
createField(99, { label: 'Thumbnail', key: 'thumbnail', type: 'media', ...rest }),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
it('flags a required media field added to a type with entries as impossible', () => {
|
||||||
|
const [change] = diffFields(baseline, withThumbnail({ required: true }), 12)
|
||||||
|
|
||||||
|
expect(change).toMatchObject({ kind: 'add', key: 'thumbnail', flag: 'cannotApply' })
|
||||||
|
expect(change!.notes[0]!.text).toBe(
|
||||||
|
"Media, required. A media field can't have a default, so the 12 existing entries would have no image. Make Thumbnail optional to add it.",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('words the single entry case apart', () => {
|
||||||
|
const [change] = diffFields(baseline, withThumbnail({ required: true }), 1)
|
||||||
|
|
||||||
|
expect(change!.notes[0]!.text).toBe(
|
||||||
|
"Media, required. A media field can't have a default, so the existing entry would have no image. Make Thumbnail optional to add it.",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[12, 'Media, optional. 12 entries are unaffected.'],
|
||||||
|
[1, 'Media, optional. 1 entry is unaffected.'],
|
||||||
|
[0, 'Media, optional.'],
|
||||||
|
])('reads the clean row for %i entries', (total, text) => {
|
||||||
|
const [change] = diffFields(baseline, withThumbnail(), total)
|
||||||
|
|
||||||
|
expect(change!.flag).toBeNull()
|
||||||
|
expect(change!.notes[0]!.text).toBe(text)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the add note of the other types unchanged by the count', () => {
|
||||||
|
const added = [...loaded(), createField(99, { label: 'Speaker', key: 'speaker' })]
|
||||||
|
|
||||||
|
expect(diffFields(baseline, added, 12)[0]!.notes[0]!.text).toBe('Text, optional.')
|
||||||
|
expect(diffFields(baseline, added)[0]!.notes[0]!.text).toBe('Text, optional.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the summary line', () => {
|
||||||
|
expect(summaryLine(diffFields(baseline, withThumbnail({ required: true }), 12))).toBe(
|
||||||
|
'Add 1 field',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags making a saved optional media field required as one that may not fit', () => {
|
||||||
|
const saved: ContentType = { ...events, fields: [...eventsFields, mediaField()] }
|
||||||
|
const base = baselinePayload(saved)
|
||||||
|
let next = 1
|
||||||
|
const fields = formFromType(saved, () => next++).fields.map((field) =>
|
||||||
|
field.key === 'thumbnail' ? { ...field, required: true } : field,
|
||||||
|
)
|
||||||
|
|
||||||
|
const [change] = diffFields(base, fields, 12)
|
||||||
|
|
||||||
|
expect(change).toMatchObject({ kind: 'change', flag: 'mayNotFit' })
|
||||||
|
expect(change!.notes[0]).toMatchObject({
|
||||||
|
text: 'Now required.',
|
||||||
|
hint: 'An entry may have no value.',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { MediaMap } from '@/lib/mediaField'
|
||||||
|
|
||||||
export interface BuiltinField {
|
export interface BuiltinField {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
@@ -24,6 +26,7 @@ export interface ContentEntry {
|
|||||||
id: number
|
id: number
|
||||||
content_type: string
|
content_type: string
|
||||||
data: Record<string, unknown>
|
data: Record<string, unknown>
|
||||||
|
media?: MediaMap
|
||||||
created_by: number
|
created_by: number
|
||||||
updated_by: number
|
updated_by: number
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { formatNumber, t } from '@/i18n'
|
|||||||
import type { BuiltinField } from '@/lib/contentTypes'
|
import type { BuiltinField } from '@/lib/contentTypes'
|
||||||
import { fieldTypeSpec, type FieldCellKind } from '@/lib/fieldTypes'
|
import { fieldTypeSpec, type FieldCellKind } from '@/lib/fieldTypes'
|
||||||
import { formatDayValue } from '@/lib/entryValue'
|
import { formatDayValue } from '@/lib/entryValue'
|
||||||
|
import { isMissingImage, type MediaSummary } from '@/lib/mediaField'
|
||||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||||
|
|
||||||
export { formatDayValue }
|
export { formatDayValue }
|
||||||
@@ -12,6 +13,7 @@ export interface CellView {
|
|||||||
kind: CellKind
|
kind: CellKind
|
||||||
text: string
|
text: string
|
||||||
yes?: boolean
|
yes?: boolean
|
||||||
|
image?: MediaSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY_TEXT = '–'
|
export const EMPTY_TEXT = '–'
|
||||||
@@ -37,12 +39,23 @@ export function richTextFirstLine(markdown: string) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function describeCell(field: BuiltinField, value: unknown): CellView {
|
function thumbnailCell(value: unknown, image: MediaSummary | undefined): CellView {
|
||||||
|
const shown = image ?? { id: Number(value), missing: true as const }
|
||||||
|
return {
|
||||||
|
kind: 'thumbnail',
|
||||||
|
text: isMissingImage(shown) ? '' : shown.filename,
|
||||||
|
image: shown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeCell(field: BuiltinField, value: unknown, image?: MediaSummary): CellView {
|
||||||
if (value === undefined || value === null || value === '') {
|
if (value === undefined || value === null || value === '') {
|
||||||
return EMPTY
|
return EMPTY
|
||||||
}
|
}
|
||||||
const kind = fieldTypeSpec(field.type).cell
|
const kind = fieldTypeSpec(field.type).cell
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
|
case 'thumbnail':
|
||||||
|
return thumbnailCell(value, image)
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return { kind, text: '', yes: value === true }
|
return { kind, text: '', yes: value === true }
|
||||||
case 'number':
|
case 'number':
|
||||||
@@ -75,7 +88,7 @@ export function plainCellText(cell: CellView) {
|
|||||||
if (cell.kind === 'boolean') {
|
if (cell.kind === 'boolean') {
|
||||||
return cell.yes ? t('Yes') : t('No')
|
return cell.yes ? t('Yes') : t('No')
|
||||||
}
|
}
|
||||||
return cell.text
|
return cell.text || EMPTY_TEXT
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardFields {
|
export interface CardFields {
|
||||||
@@ -84,8 +97,17 @@ export interface CardFields {
|
|||||||
choice?: BuiltinField
|
choice?: BuiltinField
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isThumbnail(field: BuiltinField) {
|
||||||
|
return fieldTypeSpec(field.type).cell === 'thumbnail'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function thumbnailColumn(columns: BuiltinField[]) {
|
||||||
|
return columns.find(isThumbnail)
|
||||||
|
}
|
||||||
|
|
||||||
export function cardFields(fields: BuiltinField[]): CardFields {
|
export function cardFields(fields: BuiltinField[]): CardFields {
|
||||||
const [first, second] = fields
|
const [first, ...rest] = fields
|
||||||
|
const second = rest.find((field) => !isThumbnail(field))
|
||||||
const choice = fields.find((field) => field.type === 'select')
|
const choice = fields.find((field) => field.type === 'select')
|
||||||
return { first, second, choice: choice === first ? undefined : choice }
|
return { first, second, choice: choice === first ? undefined : choice }
|
||||||
}
|
}
|
||||||
@@ -3,18 +3,23 @@ import type { BuiltinField } from '@/lib/contentTypes'
|
|||||||
import { rangeHelp, uniqueHelp, type EntryRule } from '@/lib/entryMessages'
|
import { rangeHelp, uniqueHelp, type EntryRule } from '@/lib/entryMessages'
|
||||||
import { asBoolean, asNumber, asText, charCount, isBlank, isIsoDay } from '@/lib/entryValue'
|
import { asBoolean, asNumber, asText, charCount, isBlank, isIsoDay } from '@/lib/entryValue'
|
||||||
import type { FieldType } from '@/lib/fieldTypes'
|
import type { FieldType } from '@/lib/fieldTypes'
|
||||||
|
import { isMediaId, isMissingImage, type MediaSummary } from '@/lib/mediaField'
|
||||||
|
|
||||||
export type FieldControl = 'input' | 'markdown' | 'number' | 'switch' | 'date' | 'select'
|
export type FieldControl = 'input' | 'markdown' | 'number' | 'switch' | 'date' | 'select' | 'media'
|
||||||
|
|
||||||
export interface FieldCounter {
|
export interface FieldCounter {
|
||||||
text: string
|
text: string
|
||||||
over: boolean
|
over: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EntryContext {
|
||||||
|
image: MediaSummary | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface EntryEditorSpec {
|
export interface EntryEditorSpec {
|
||||||
control: FieldControl
|
control: FieldControl
|
||||||
initial: (field: BuiltinField, stored: unknown, creating: boolean) => unknown
|
initial: (field: BuiltinField, stored: unknown, creating: boolean) => unknown
|
||||||
validate: (field: BuiltinField, value: unknown) => EntryRule | null
|
validate: (field: BuiltinField, value: unknown, context?: EntryContext) => EntryRule | null
|
||||||
payload: (field: BuiltinField, value: unknown) => unknown
|
payload: (field: BuiltinField, value: unknown) => unknown
|
||||||
help: (field: BuiltinField) => string
|
help: (field: BuiltinField) => string
|
||||||
counter: (field: BuiltinField, value: unknown) => FieldCounter | null
|
counter: (field: BuiltinField, value: unknown) => FieldCounter | null
|
||||||
@@ -108,6 +113,21 @@ function booleanPayload(field: BuiltinField, value: unknown) {
|
|||||||
return field.required ? false : null
|
return field.required ? false : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mediaViolation(
|
||||||
|
field: BuiltinField,
|
||||||
|
value: unknown,
|
||||||
|
context?: EntryContext,
|
||||||
|
): EntryRule | null {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return field.required ? 'required' : null
|
||||||
|
}
|
||||||
|
if (!isMediaId(value)) {
|
||||||
|
return 'type'
|
||||||
|
}
|
||||||
|
const gone = context?.image ? isMissingImage(context.image) : false
|
||||||
|
return field.required && gone ? 'required' : null
|
||||||
|
}
|
||||||
|
|
||||||
function noHelp() {
|
function noHelp() {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -169,4 +189,12 @@ export const ENTRY_EDITORS: Record<FieldType, EntryEditorSpec> = {
|
|||||||
help: noHelp,
|
help: noHelp,
|
||||||
counter: noCounter,
|
counter: noCounter,
|
||||||
},
|
},
|
||||||
|
media: {
|
||||||
|
control: 'media',
|
||||||
|
initial: (_field, stored, creating) => (!creating && isMediaId(stored) ? stored : null),
|
||||||
|
validate: mediaViolation,
|
||||||
|
payload: (_field, value) => (value === undefined ? null : value),
|
||||||
|
help: () => t('One image from your library.'),
|
||||||
|
counter: noCounter,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { describeCell, plainCellText } from '@/lib/entryCells'
|
|||||||
import { ruleMessage, type EntryRule } from '@/lib/entryMessages'
|
import { ruleMessage, type EntryRule } from '@/lib/entryMessages'
|
||||||
import { isBlank } from '@/lib/entryValue'
|
import { isBlank } from '@/lib/entryValue'
|
||||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||||
|
import { mediaOf, type MediaMap } from '@/lib/mediaField'
|
||||||
|
|
||||||
export type EntryValues = Record<string, unknown>
|
export type EntryValues = Record<string, unknown>
|
||||||
|
|
||||||
@@ -27,6 +28,17 @@ export function initialValues(fields: BuiltinField[], stored: ContentEntry | nul
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function initialImages(fields: BuiltinField[], stored: ContentEntry | null): MediaMap {
|
||||||
|
const images: MediaMap = {}
|
||||||
|
for (const field of fields) {
|
||||||
|
const summary = mediaOf(stored, field.key)
|
||||||
|
if (summary && stored?.data[field.key] === summary.id) {
|
||||||
|
images[field.key] = summary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
export function buildData(fields: BuiltinField[], values: EntryValues) {
|
export function buildData(fields: BuiltinField[], values: EntryValues) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
fields.map((field) => [
|
fields.map((field) => [
|
||||||
@@ -40,10 +52,16 @@ export function issueFor(field: BuiltinField, rule: EntryRule): FieldIssue {
|
|||||||
return { rule, message: ruleMessage(rule, field) }
|
return { rule, message: ruleMessage(rule, field) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateEntry(fields: BuiltinField[], values: EntryValues): EntryIssues {
|
export function validateEntry(
|
||||||
|
fields: BuiltinField[],
|
||||||
|
values: EntryValues,
|
||||||
|
images: MediaMap = {},
|
||||||
|
): EntryIssues {
|
||||||
const issues: EntryIssues = {}
|
const issues: EntryIssues = {}
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
const rule = fieldTypeSpec(field.type).editor.validate(field, values[field.key])
|
const rule = fieldTypeSpec(field.type).editor.validate(field, values[field.key], {
|
||||||
|
image: images[field.key] ?? null,
|
||||||
|
})
|
||||||
if (rule) {
|
if (rule) {
|
||||||
issues[field.key] = issueFor(field, rule)
|
issues[field.key] = issueFor(field, rule)
|
||||||
}
|
}
|
||||||
@@ -73,5 +91,7 @@ export function entryTitle(fields: BuiltinField[], entry: ContentEntry | null) {
|
|||||||
return entry ? t('Entry {0}', [entry.id]) : t('New entry')
|
return entry ? t('Entry {0}', [entry.id]) : t('New entry')
|
||||||
}
|
}
|
||||||
const cell = describeCell(first, entry.data[first.key])
|
const cell = describeCell(first, entry.data[first.key])
|
||||||
return cell.kind === 'empty' ? t('Entry {0}', [entry.id]) : plainCellText(cell)
|
return cell.kind === 'empty' || cell.kind === 'thumbnail'
|
||||||
|
? t('Entry {0}', [entry.id])
|
||||||
|
: plainCellText(cell)
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,8 @@ function requiredMessage(field: BuiltinField) {
|
|||||||
return t('Choose an option for {0}.', [field.label])
|
return t('Choose an option for {0}.', [field.label])
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return t('Choose yes or no for {0}.', [field.label])
|
return t('Choose yes or no for {0}.', [field.label])
|
||||||
|
case 'media':
|
||||||
|
return t('Pick an image for {0}.', [field.label])
|
||||||
default:
|
default:
|
||||||
return t('Fill in {0}.', [field.label])
|
return t('Fill in {0}.', [field.label])
|
||||||
}
|
}
|
||||||
@@ -39,6 +41,8 @@ function typeMessage(field: BuiltinField) {
|
|||||||
return t('{0} must be a date, like 2026-11-21.', [field.label])
|
return t('{0} must be a date, like 2026-11-21.', [field.label])
|
||||||
case 'select':
|
case 'select':
|
||||||
return t('{0} must be one of the choices.', [field.label])
|
return t('{0} must be one of the choices.', [field.label])
|
||||||
|
case 'media':
|
||||||
|
return t('{0} must be an image from the library.', [field.label])
|
||||||
default:
|
default:
|
||||||
return t('{0} must be text.', [field.label])
|
return t('{0} must be text.', [field.label])
|
||||||
}
|
}
|
||||||
@@ -134,6 +138,13 @@ export function rangeHelp(field: BuiltinField) {
|
|||||||
return typeof max === 'number' ? t('At most {0}.', [String(max)]) : ''
|
return typeof max === 'number' ? t('At most {0}.', [String(max)]) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mediaChangedLine(label: string) {
|
||||||
|
return t(
|
||||||
|
"The {0} field was changed after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.",
|
||||||
|
[label],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function schemaChangedLine(key: string) {
|
export function schemaChangedLine(key: string) {
|
||||||
const name = key.replace(/_/g, ' ')
|
const name = key.replace(/_/g, ' ')
|
||||||
return t(
|
return t(
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { ENTRY_EDITORS, type EntryEditorSpec } from '@/lib/entryEditors'
|
import { ENTRY_EDITORS, type EntryEditorSpec } from '@/lib/entryEditors'
|
||||||
|
|
||||||
export const FIELD_TYPES = ['text', 'richtext', 'number', 'boolean', 'date', 'select'] as const
|
export const FIELD_TYPES = [
|
||||||
|
'text',
|
||||||
|
'richtext',
|
||||||
|
'number',
|
||||||
|
'boolean',
|
||||||
|
'date',
|
||||||
|
'select',
|
||||||
|
'media',
|
||||||
|
] as const
|
||||||
|
|
||||||
export type FieldType = (typeof FIELD_TYPES)[number]
|
export type FieldType = (typeof FIELD_TYPES)[number]
|
||||||
|
|
||||||
export type FieldCellKind = 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'choice'
|
export type FieldCellKind =
|
||||||
|
| 'text'
|
||||||
|
| 'richtext'
|
||||||
|
| 'number'
|
||||||
|
| 'boolean'
|
||||||
|
| 'date'
|
||||||
|
| 'choice'
|
||||||
|
| 'thumbnail'
|
||||||
|
|
||||||
export interface FieldTypeSpec {
|
export interface FieldTypeSpec {
|
||||||
icon: string
|
icon: string
|
||||||
@@ -16,6 +31,10 @@ export interface FieldTypeSpec {
|
|||||||
alignEnd: boolean
|
alignEnd: boolean
|
||||||
supportsUnique: boolean
|
supportsUnique: boolean
|
||||||
supportsLimits: boolean
|
supportsLimits: boolean
|
||||||
|
supportsDefault: boolean
|
||||||
|
supportsChoices: boolean
|
||||||
|
showsUnsupportedOptions: boolean
|
||||||
|
builderHint: (() => string) | null
|
||||||
pairs: boolean
|
pairs: boolean
|
||||||
editor: EntryEditorSpec
|
editor: EntryEditorSpec
|
||||||
}
|
}
|
||||||
@@ -32,6 +51,10 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
|
|||||||
alignEnd: false,
|
alignEnd: false,
|
||||||
supportsUnique: true,
|
supportsUnique: true,
|
||||||
supportsLimits: true,
|
supportsLimits: true,
|
||||||
|
supportsDefault: true,
|
||||||
|
supportsChoices: false,
|
||||||
|
showsUnsupportedOptions: true,
|
||||||
|
builderHint: null,
|
||||||
pairs: false,
|
pairs: false,
|
||||||
editor: ENTRY_EDITORS.text,
|
editor: ENTRY_EDITORS.text,
|
||||||
},
|
},
|
||||||
@@ -44,6 +67,10 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
|
|||||||
alignEnd: false,
|
alignEnd: false,
|
||||||
supportsUnique: false,
|
supportsUnique: false,
|
||||||
supportsLimits: true,
|
supportsLimits: true,
|
||||||
|
supportsDefault: true,
|
||||||
|
supportsChoices: false,
|
||||||
|
showsUnsupportedOptions: true,
|
||||||
|
builderHint: null,
|
||||||
pairs: false,
|
pairs: false,
|
||||||
editor: ENTRY_EDITORS.richtext,
|
editor: ENTRY_EDITORS.richtext,
|
||||||
},
|
},
|
||||||
@@ -56,6 +83,10 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
|
|||||||
alignEnd: true,
|
alignEnd: true,
|
||||||
supportsUnique: true,
|
supportsUnique: true,
|
||||||
supportsLimits: true,
|
supportsLimits: true,
|
||||||
|
supportsDefault: true,
|
||||||
|
supportsChoices: false,
|
||||||
|
showsUnsupportedOptions: true,
|
||||||
|
builderHint: null,
|
||||||
pairs: true,
|
pairs: true,
|
||||||
editor: ENTRY_EDITORS.number,
|
editor: ENTRY_EDITORS.number,
|
||||||
},
|
},
|
||||||
@@ -68,6 +99,10 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
|
|||||||
alignEnd: false,
|
alignEnd: false,
|
||||||
supportsUnique: false,
|
supportsUnique: false,
|
||||||
supportsLimits: false,
|
supportsLimits: false,
|
||||||
|
supportsDefault: true,
|
||||||
|
supportsChoices: false,
|
||||||
|
showsUnsupportedOptions: true,
|
||||||
|
builderHint: null,
|
||||||
pairs: true,
|
pairs: true,
|
||||||
editor: ENTRY_EDITORS.boolean,
|
editor: ENTRY_EDITORS.boolean,
|
||||||
},
|
},
|
||||||
@@ -80,6 +115,10 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
|
|||||||
alignEnd: false,
|
alignEnd: false,
|
||||||
supportsUnique: true,
|
supportsUnique: true,
|
||||||
supportsLimits: true,
|
supportsLimits: true,
|
||||||
|
supportsDefault: true,
|
||||||
|
supportsChoices: false,
|
||||||
|
showsUnsupportedOptions: true,
|
||||||
|
builderHint: null,
|
||||||
pairs: true,
|
pairs: true,
|
||||||
editor: ENTRY_EDITORS.date,
|
editor: ENTRY_EDITORS.date,
|
||||||
},
|
},
|
||||||
@@ -92,9 +131,32 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
|
|||||||
alignEnd: false,
|
alignEnd: false,
|
||||||
supportsUnique: true,
|
supportsUnique: true,
|
||||||
supportsLimits: false,
|
supportsLimits: false,
|
||||||
|
supportsDefault: true,
|
||||||
|
supportsChoices: true,
|
||||||
|
showsUnsupportedOptions: true,
|
||||||
|
builderHint: null,
|
||||||
pairs: false,
|
pairs: false,
|
||||||
editor: ENTRY_EDITORS.select,
|
editor: ENTRY_EDITORS.select,
|
||||||
},
|
},
|
||||||
|
media: {
|
||||||
|
icon: 'i-lucide-image',
|
||||||
|
label: () => t('Media'),
|
||||||
|
description: () => t('One image from your media library'),
|
||||||
|
cell: 'thumbnail',
|
||||||
|
narrowColumn: true,
|
||||||
|
alignEnd: false,
|
||||||
|
supportsUnique: false,
|
||||||
|
supportsLimits: false,
|
||||||
|
supportsDefault: false,
|
||||||
|
supportsChoices: false,
|
||||||
|
showsUnsupportedOptions: false,
|
||||||
|
builderHint: () =>
|
||||||
|
t(
|
||||||
|
'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.',
|
||||||
|
),
|
||||||
|
pairs: false,
|
||||||
|
editor: ENTRY_EDITORS.media,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isFieldType(type: string): type is FieldType {
|
export function isFieldType(type: string): type is FieldType {
|
||||||
|
|||||||
@@ -5,3 +5,8 @@ export interface ImageChoice {
|
|||||||
square?: boolean
|
square?: boolean
|
||||||
confirmLabel: string
|
confirmLabel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FieldPick {
|
||||||
|
optional: boolean
|
||||||
|
currentId: number | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { t } from '@/i18n'
|
||||||
|
import type { MediaItem } from '@/composables/useMedia'
|
||||||
|
import type { ContentEntry } from '@/lib/contentTypes'
|
||||||
|
import { formatDimensions, formatSize } from '@/lib/mediaFormat'
|
||||||
|
|
||||||
|
const MEDIA_ID_MAX = 4294967295
|
||||||
|
|
||||||
|
export interface MediaImage {
|
||||||
|
id: number
|
||||||
|
filename: string
|
||||||
|
width: number | null
|
||||||
|
height: number | null
|
||||||
|
size: number
|
||||||
|
thumbnail_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissingImage {
|
||||||
|
id: number
|
||||||
|
missing: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaSummary = MediaImage | MissingImage
|
||||||
|
|
||||||
|
export type MediaMap = Record<string, MediaSummary>
|
||||||
|
|
||||||
|
export function isMissingImage(image: MediaSummary): image is MissingImage {
|
||||||
|
return 'missing' in image
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMediaId(value: unknown): value is number {
|
||||||
|
return (
|
||||||
|
typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= MEDIA_ID_MAX
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaOf(
|
||||||
|
entry: Pick<ContentEntry, 'media'> | null | undefined,
|
||||||
|
key: string,
|
||||||
|
): MediaSummary | null {
|
||||||
|
return entry?.media?.[key] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageOfItem(item: MediaItem): MediaImage {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
filename: item.filename,
|
||||||
|
width: item.width,
|
||||||
|
height: item.height,
|
||||||
|
size: item.size,
|
||||||
|
thumbnail_url: item.url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageMeta(image: MediaImage) {
|
||||||
|
return `${formatDimensions(image.width, image.height)} · ${formatSize(image.size)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageButtonName(image: MediaImage) {
|
||||||
|
const dimensions =
|
||||||
|
image.width === null || image.height === null
|
||||||
|
? formatDimensions(null, null)
|
||||||
|
: t('{0} by {1}', [String(image.width), String(image.height)])
|
||||||
|
return t('Change image, {0}, {1}, {2}', [image.filename, dimensions, formatSize(image.size)])
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { t } from '@/i18n'
|
import { formatNumber, t } from '@/i18n'
|
||||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||||
import type { BuilderField } from '@/lib/schemaBuilderModel'
|
import type { BuilderField } from '@/lib/schemaBuilderModel'
|
||||||
import { payloadField, type PayloadField } from '@/lib/schemaBuilderPayload'
|
import { payloadField, type PayloadField } from '@/lib/schemaBuilderPayload'
|
||||||
|
|
||||||
export type ChangeKind = 'add' | 'change' | 'remove'
|
export type ChangeKind = 'add' | 'change' | 'remove'
|
||||||
export type ChangeFlag = 'mayNotFit' | 'removesData'
|
export type ChangeFlag = 'mayNotFit' | 'removesData' | 'cannotApply'
|
||||||
|
|
||||||
export interface ChangeNote {
|
export interface ChangeNote {
|
||||||
text: string
|
text: string
|
||||||
@@ -19,6 +19,7 @@ export interface FieldChange {
|
|||||||
label: string
|
label: string
|
||||||
notes: ChangeNote[]
|
notes: ChangeNote[]
|
||||||
flag: ChangeFlag | null
|
flag: ChangeFlag | null
|
||||||
|
mediaAdd?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Bound = number | string | null
|
type Bound = number | string | null
|
||||||
@@ -150,7 +151,35 @@ function changeNotes(before: PayloadField, after: PayloadField) {
|
|||||||
return notes
|
return notes
|
||||||
}
|
}
|
||||||
|
|
||||||
function addNote(field: PayloadField) {
|
function mediaAddNote(field: PayloadField, total: number) {
|
||||||
|
const summary = `${fieldTypeSpec(field.type).label()}, ${field.required ? t('required') : t('optional')}.`
|
||||||
|
if (field.required) {
|
||||||
|
const cause =
|
||||||
|
total === 1
|
||||||
|
? t(
|
||||||
|
"A media field can't have a default, so the existing entry would have no image. Make {0} optional to add it.",
|
||||||
|
[field.label],
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"A media field can't have a default, so the {0} existing entries would have no image. Make {1} optional to add it.",
|
||||||
|
[formatNumber(total), field.label],
|
||||||
|
)
|
||||||
|
return note(`${summary} ${cause}`, '', true)
|
||||||
|
}
|
||||||
|
if (total <= 0) {
|
||||||
|
return note(summary)
|
||||||
|
}
|
||||||
|
const unaffected =
|
||||||
|
total === 1
|
||||||
|
? t('1 entry is unaffected.')
|
||||||
|
: t('{0} entries are unaffected.', [formatNumber(total)])
|
||||||
|
return note(`${summary} ${unaffected}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNote(field: PayloadField, total: number) {
|
||||||
|
if (!fieldTypeSpec(field.type).supportsDefault) {
|
||||||
|
return mediaAddNote(field, total)
|
||||||
|
}
|
||||||
const parts = [
|
const parts = [
|
||||||
fieldTypeSpec(field.type).label(),
|
fieldTypeSpec(field.type).label(),
|
||||||
field.required ? t('required') : t('optional'),
|
field.required ? t('required') : t('optional'),
|
||||||
@@ -175,9 +204,19 @@ function flagOf(notes: readonly ChangeNote[]): ChangeFlag | null {
|
|||||||
return notes.some((entry) => entry.flagged) ? 'mayNotFit' : null
|
return notes.some((entry) => entry.flagged) ? 'mayNotFit' : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addFlag(
|
||||||
|
field: PayloadField,
|
||||||
|
notes: readonly ChangeNote[],
|
||||||
|
total: number,
|
||||||
|
): ChangeFlag | null {
|
||||||
|
const refused = !fieldTypeSpec(field.type).supportsDefault && field.required && total > 0
|
||||||
|
return refused ? 'cannotApply' : flagOf(notes)
|
||||||
|
}
|
||||||
|
|
||||||
export function diffFields(
|
export function diffFields(
|
||||||
baseline: readonly PayloadField[],
|
baseline: readonly PayloadField[],
|
||||||
fields: readonly BuilderField[],
|
fields: readonly BuilderField[],
|
||||||
|
total = 0,
|
||||||
): FieldChange[] {
|
): FieldChange[] {
|
||||||
const before = new Map(baseline.map((field) => [field.key, field]))
|
const before = new Map(baseline.map((field) => [field.key, field]))
|
||||||
const additions: FieldChange[] = []
|
const additions: FieldChange[] = []
|
||||||
@@ -186,14 +225,15 @@ export function diffFields(
|
|||||||
const after = payloadField(field)
|
const after = payloadField(field)
|
||||||
const previous = field.saved ? before.get(field.key) : undefined
|
const previous = field.saved ? before.get(field.key) : undefined
|
||||||
if (!field.saved || !previous) {
|
if (!field.saved || !previous) {
|
||||||
const notes = [addNote(after)]
|
const notes = [addNote(after, total)]
|
||||||
additions.push({
|
additions.push({
|
||||||
kind: 'add',
|
kind: 'add',
|
||||||
uid: field.uid,
|
uid: field.uid,
|
||||||
key: after.key,
|
key: after.key,
|
||||||
label: after.label,
|
label: after.label,
|
||||||
notes,
|
notes,
|
||||||
flag: flagOf(notes),
|
flag: addFlag(after, notes, total),
|
||||||
|
...(fieldTypeSpec(after.type).supportsDefault ? {} : { mediaAdd: true }),
|
||||||
})
|
})
|
||||||
} else if (fieldSignature(previous) !== fieldSignature(after)) {
|
} else if (fieldSignature(previous) !== fieldSignature(after)) {
|
||||||
const notes = changeNotes(previous, after)
|
const notes = changeNotes(previous, after)
|
||||||
@@ -247,6 +287,14 @@ export function flaggedChanges(changes: readonly FieldChange[]) {
|
|||||||
return changes.filter((change) => change.flag === 'mayNotFit')
|
return changes.filter((change) => change.flag === 'mayNotFit')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMediaOnlyClean(changes: readonly FieldChange[]) {
|
||||||
|
return changes.length > 0 && changes.every((change) => change.mediaAdd && change.flag === null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRefusedChange(changes: readonly FieldChange[]) {
|
||||||
|
return changes.some((change) => change.flag === 'cannotApply')
|
||||||
|
}
|
||||||
|
|
||||||
export function summaryLine(changes: readonly FieldChange[]) {
|
export function summaryLine(changes: readonly FieldChange[]) {
|
||||||
const count = (kind: ChangeKind) => changes.filter((change) => change.kind === kind).length
|
const count = (kind: ChangeKind) => changes.filter((change) => change.kind === kind).length
|
||||||
const added = count('add')
|
const added = count('add')
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ export function supportsUnique(type: FieldType) {
|
|||||||
return FIELD_TYPE_SPECS[type].supportsUnique
|
return FIELD_TYPE_SPECS[type].supportsUnique
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function supportsChoices(type: FieldType) {
|
||||||
|
return FIELD_TYPE_SPECS[type].supportsChoices
|
||||||
|
}
|
||||||
|
|
||||||
export function supportsLimits(type: FieldType) {
|
export function supportsLimits(type: FieldType) {
|
||||||
return FIELD_TYPE_SPECS[type].supportsLimits
|
return FIELD_TYPE_SPECS[type].supportsLimits
|
||||||
}
|
}
|
||||||
@@ -132,8 +136,12 @@ export function isUnique(field: BuilderField) {
|
|||||||
return field.unique && supportsUnique(field.type)
|
return field.unique && supportsUnique(field.type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function supportsDefault(type: FieldType) {
|
||||||
|
return FIELD_TYPE_SPECS[type].supportsDefault
|
||||||
|
}
|
||||||
|
|
||||||
export function effectiveDefault(field: BuilderField) {
|
export function effectiveDefault(field: BuilderField) {
|
||||||
if (isUnique(field) || field.defaultValue === '') {
|
if (!supportsDefault(field.type) || isUnique(field) || field.defaultValue === '') {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
if (field.type === 'select') {
|
if (field.type === 'select') {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
effectiveDefault,
|
effectiveDefault,
|
||||||
isUnique,
|
isUnique,
|
||||||
|
supportsChoices,
|
||||||
supportsLimits,
|
supportsLimits,
|
||||||
trimmedOptions,
|
trimmedOptions,
|
||||||
type BuilderField,
|
type BuilderField,
|
||||||
@@ -59,7 +60,7 @@ export function payloadField(field: BuilderField): PayloadField {
|
|||||||
default: defaultFor(field),
|
default: defaultFor(field),
|
||||||
min: limit(field, field.min),
|
min: limit(field, field.min),
|
||||||
max: limit(field, field.max),
|
max: limit(field, field.max),
|
||||||
options: field.type === 'select' ? trimmedOptions(field) : [],
|
options: supportsChoices(field.type) ? trimmedOptions(field) : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export function sampleValue(field: BuilderField): string | number | boolean | nu
|
|||||||
return field.defaultOn
|
return field.defaultOn
|
||||||
case 'date':
|
case 'date':
|
||||||
return given === '' ? SAMPLE_DATE : given
|
return given === '' ? SAMPLE_DATE : given
|
||||||
|
case 'media':
|
||||||
|
return null
|
||||||
case 'select': {
|
case 'select': {
|
||||||
const choices = trimmedOptions(field).filter((option) => option !== '')
|
const choices = trimmedOptions(field).filter((option) => option !== '')
|
||||||
return given !== '' ? given : (choices[0] ?? null)
|
return given !== '' ? given : (choices[0] ?? null)
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import router from '@/router'
|
||||||
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import type { MediaSummary } from '@/lib/mediaField'
|
||||||
|
import {
|
||||||
|
calls,
|
||||||
|
eventEntries,
|
||||||
|
installSelectPolyfills,
|
||||||
|
listPage,
|
||||||
|
mountList,
|
||||||
|
text,
|
||||||
|
unmountAll,
|
||||||
|
} from './contentTestKit'
|
||||||
|
import { drawsIcon } from './mediaFieldKit'
|
||||||
|
import { eventsWithThumbnail } from './schemaTestKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeAll(installSelectPolyfills)
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
void router
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
await unmountAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
const KEY = 'renewcms-content-columns-events'
|
||||||
|
const COLUMNS = ['title', 'thumbnail', 'starts_on', 'category']
|
||||||
|
|
||||||
|
function image(id: number, filename: string): MediaSummary {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
filename,
|
||||||
|
width: 1600,
|
||||||
|
height: 900,
|
||||||
|
size: 245760,
|
||||||
|
thumbnail_url: `/media/${filename}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const THUMBNAILS: Record<number, [number | null, MediaSummary | null]> = {
|
||||||
|
42: [7, image(7, 'lime-plaster-workshop.jpg')],
|
||||||
|
41: [8, image(8, 'bathroom-tiles.png')],
|
||||||
|
40: [null, null],
|
||||||
|
39: [9, { id: 9, missing: true }],
|
||||||
|
38: [10, image(10, 'timber-joinery.jpg')],
|
||||||
|
37: [null, null],
|
||||||
|
36: [11, image(11, 'floorboards.jpg')],
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = eventEntries.map((entry) => {
|
||||||
|
const [thumbnail, summary] = THUMBNAILS[entry.id]!
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
data: { ...entry.data, thumbnail },
|
||||||
|
...(summary ? { media: { thumbnail: summary } } : {}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function thumbnailSecond() {
|
||||||
|
const type = eventsWithThumbnail()
|
||||||
|
const [title, ...rest] = type.fields
|
||||||
|
const thumbnail = rest.pop()!
|
||||||
|
return { ...type, fields: [title!, thumbnail, ...rest] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbnailFirst() {
|
||||||
|
const type = eventsWithThumbnail()
|
||||||
|
const thumbnail = type.fields[type.fields.length - 1]!
|
||||||
|
return { ...type, fields: [thumbnail, ...type.fields.slice(0, -1)] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountWithThumbnail(
|
||||||
|
width?: number,
|
||||||
|
columns: string[] | null = COLUMNS,
|
||||||
|
type: () => ReturnType<typeof eventsWithThumbnail> = thumbnailSecond,
|
||||||
|
) {
|
||||||
|
return mountList('/content/events', {
|
||||||
|
width,
|
||||||
|
before: () => {
|
||||||
|
if (columns) {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(columns))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type,
|
||||||
|
content: () => [type()],
|
||||||
|
list: (_key, query) => listPage(items, 7, query),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = () => [...document.body.querySelectorAll('thead th')]
|
||||||
|
const rowsOf = () => [...document.body.querySelectorAll('tbody tr')]
|
||||||
|
const thumbCell = (row: number) => rowsOf()[row]!.querySelectorAll('td')[2]!
|
||||||
|
|
||||||
|
describe('The Thumbnail column of the table', () => {
|
||||||
|
it('has a Thumbnail header between Title and Starts on, 80 px wide, with no sort', async () => {
|
||||||
|
await mountWithThumbnail()
|
||||||
|
|
||||||
|
expect(headers().map(text)).toEqual([
|
||||||
|
'ID',
|
||||||
|
'Title',
|
||||||
|
'Thumbnail',
|
||||||
|
'Starts on',
|
||||||
|
'Category',
|
||||||
|
'Created',
|
||||||
|
'Updated',
|
||||||
|
'Updated by',
|
||||||
|
])
|
||||||
|
const header = headers()[2]!
|
||||||
|
expect(header.querySelector('svg')).toBeNull()
|
||||||
|
expect(header.hasAttribute('aria-sort')).toBe(false)
|
||||||
|
expect(header.className).toContain('w-23')
|
||||||
|
expect(header.querySelector('span')!.className).not.toContain('justify-end')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('follows the schema order of the type, so Thumbnail after Description comes last', async () => {
|
||||||
|
await mountWithThumbnail(undefined, COLUMNS, eventsWithThumbnail)
|
||||||
|
|
||||||
|
expect(headers().map(text).slice(0, 5)).toEqual([
|
||||||
|
'ID',
|
||||||
|
'Title',
|
||||||
|
'Starts on',
|
||||||
|
'Category',
|
||||||
|
'Thumbnail',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws a 40 px thumbnail from the item media with the filename as alt', async () => {
|
||||||
|
await mountWithThumbnail()
|
||||||
|
|
||||||
|
const img = thumbCell(0).querySelector('img')!
|
||||||
|
expect(img.getAttribute('src')).toBe('/media/lime-plaster-workshop.jpg')
|
||||||
|
expect(img.getAttribute('alt')).toBe('lime-plaster-workshop.jpg')
|
||||||
|
expect(img.parentElement!.className).toContain('size-10')
|
||||||
|
expect(rowsOf()[0]!.querySelector('td')!.className).toContain('h-12')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a muted dash for no image', async () => {
|
||||||
|
await mountWithThumbnail()
|
||||||
|
|
||||||
|
const cell = thumbCell(2)
|
||||||
|
expect(text(cell)).toBe('–')
|
||||||
|
expect(cell.querySelector('span')!.className).toContain('text-muted')
|
||||||
|
expect(cell.querySelector('img')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a dashed 40 px square with the unlink icon and the missing name for a gone image', async () => {
|
||||||
|
await mountWithThumbnail()
|
||||||
|
|
||||||
|
const placeholder = thumbCell(3).querySelector('[role="img"]')!
|
||||||
|
expect(placeholder.getAttribute('aria-label')).toBe('Image no longer in the library')
|
||||||
|
expect(placeholder.className).toContain('size-10')
|
||||||
|
expect(placeholder.className).toContain('border-dashed')
|
||||||
|
expect(drawsIcon(placeholder, 'unlink')).toBe(true)
|
||||||
|
expect(thumbCell(3).querySelector('img')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('makes no request for the thumbnails', async () => {
|
||||||
|
await mountWithThumbnail()
|
||||||
|
|
||||||
|
expect(calls.filter((url) => url.startsWith('/v1/media'))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a media first column as the link to the editor', async () => {
|
||||||
|
await mountWithThumbnail(undefined, ['thumbnail', 'title'], thumbnailFirst)
|
||||||
|
|
||||||
|
const link = rowsOf()[0]!.querySelector<HTMLAnchorElement>('[data-testid="entry-link"]')!
|
||||||
|
expect(link.getAttribute('href')).toBe('/content/events/42')
|
||||||
|
expect(link.querySelector('img')).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The tooltip of the missing placeholder', () => {
|
||||||
|
it('reads the missing copy on hover on desktop', async () => {
|
||||||
|
await mountWithThumbnail()
|
||||||
|
const placeholder = thumbCell(3).querySelector<HTMLElement>('[role="img"]')!
|
||||||
|
|
||||||
|
placeholder.focus()
|
||||||
|
placeholder.dispatchEvent(new MouseEvent('pointermove', { bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(placeholder.tabIndex).toBe(0)
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect([...document.body.querySelectorAll('[role="tooltip"]')].map(text)).toContain(
|
||||||
|
'Image no longer in the library',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The cards on mobile', () => {
|
||||||
|
const cards = () => [...document.body.querySelectorAll('[data-testid="entry-card"]')]
|
||||||
|
|
||||||
|
it('leads each card with a 32 px thumbnail and keeps the minimum height', async () => {
|
||||||
|
await mountWithThumbnail(MOBILE_WIDTH)
|
||||||
|
|
||||||
|
const first = cards()[0]!
|
||||||
|
const lead = first.firstElementChild!
|
||||||
|
expect(lead.querySelector('img')!.parentElement!.className).toContain('size-8')
|
||||||
|
expect(first.className).toContain('min-h-17')
|
||||||
|
expect(text(first)).toContain('Lime plaster workshop')
|
||||||
|
expect(text(first)).toContain('21 Nov 2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws a dash for none and a dashed 32 px square for a missing image', async () => {
|
||||||
|
await mountWithThumbnail(MOBILE_WIDTH)
|
||||||
|
|
||||||
|
expect(text(cards()[2]!.firstElementChild)).toBe('–')
|
||||||
|
const placeholder = cards()[3]!.querySelector('[role="img"]')!
|
||||||
|
expect(placeholder.className).toContain('size-8')
|
||||||
|
expect(placeholder.className).toContain('border-dashed')
|
||||||
|
expect(placeholder.getAttribute('aria-label')).toBe('Image no longer in the library')
|
||||||
|
expect(placeholder.getAttribute('tabindex')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the card unchanged when no media column is chosen', async () => {
|
||||||
|
await mountWithThumbnail(MOBILE_WIDTH, ['title', 'starts_on', 'category'])
|
||||||
|
|
||||||
|
const first = cards()[0]!
|
||||||
|
expect(first.querySelector('img')).toBeNull()
|
||||||
|
expect(first.querySelector('[aria-label="Image no longer in the library"]')).toBeNull()
|
||||||
|
expect([...first.children].filter((child) => child.tagName === 'SPAN')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads Entry N as the title when the first column is media and never the id', async () => {
|
||||||
|
await mountWithThumbnail(MOBILE_WIDTH, ['thumbnail', 'title', 'starts_on'], thumbnailFirst)
|
||||||
|
|
||||||
|
const first = cards()[0]!
|
||||||
|
expect(text(first.querySelector('.font-medium'))).toBe('Entry 42')
|
||||||
|
expect(text(first)).not.toContain(' 7 ')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The Columns menu', () => {
|
||||||
|
function openColumns() {
|
||||||
|
const trigger = [...document.body.querySelectorAll('button')].find(
|
||||||
|
(candidate) =>
|
||||||
|
text(candidate) === 'Columns' || candidate.getAttribute('aria-label') === 'Columns',
|
||||||
|
)!
|
||||||
|
trigger.click()
|
||||||
|
return flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lists Thumbnail with the image icon, its key and a checkbox, with 4 of 6', async () => {
|
||||||
|
await mountWithThumbnail(undefined, COLUMNS, eventsWithThumbnail)
|
||||||
|
|
||||||
|
await openColumns()
|
||||||
|
|
||||||
|
const row = [...document.body.querySelectorAll('[data-testid="column-row"]')].find(
|
||||||
|
(candidate) => text(candidate).includes('Thumbnail'),
|
||||||
|
)!
|
||||||
|
expect(text(row)).toBe('Thumbnail thumbnail')
|
||||||
|
expect(drawsIcon(row, 'image')).toBe(true)
|
||||||
|
expect(row.querySelector('[role="checkbox"]')!.getAttribute('aria-checked')).toBe('true')
|
||||||
|
expect(text(document.body.querySelector('[data-testid="column-picker"]'))).toContain(
|
||||||
|
'4 of 6',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the mobile summary and keeps the cap and the stored choice', async () => {
|
||||||
|
await mountWithThumbnail(MOBILE_WIDTH, COLUMNS, eventsWithThumbnail)
|
||||||
|
|
||||||
|
await openColumns()
|
||||||
|
|
||||||
|
expect(text(document.body.querySelector('[role="dialog"]'))).toContain(
|
||||||
|
'Choose up to 6 fields to show. 4 selected.',
|
||||||
|
)
|
||||||
|
const row = [...document.body.querySelectorAll('[data-testid="column-row"]')].find(
|
||||||
|
(candidate) => text(candidate).includes('Thumbnail'),
|
||||||
|
)!
|
||||||
|
row.querySelector<HTMLElement>('[role="checkbox"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(JSON.parse(localStorage.getItem(KEY)!)).toEqual(['title', 'starts_on', 'category'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers Thumbnail unchecked by default beyond the first four fields', async () => {
|
||||||
|
await mountWithThumbnail(undefined, null, eventsWithThumbnail)
|
||||||
|
|
||||||
|
await openColumns()
|
||||||
|
|
||||||
|
const row = [...document.body.querySelectorAll('[data-testid="column-row"]')].find(
|
||||||
|
(candidate) => text(candidate).includes('Thumbnail'),
|
||||||
|
)!
|
||||||
|
expect(row.querySelector('[role="checkbox"]')!.getAttribute('aria-checked')).toBe('false')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import type { MediaSummary } from '@/lib/mediaField'
|
||||||
|
import {
|
||||||
|
answerWrite,
|
||||||
|
button,
|
||||||
|
calls,
|
||||||
|
chip,
|
||||||
|
entry42,
|
||||||
|
entryWrites,
|
||||||
|
failWithBody,
|
||||||
|
fieldWrapper,
|
||||||
|
hasText,
|
||||||
|
main,
|
||||||
|
mountEditor,
|
||||||
|
pending,
|
||||||
|
save,
|
||||||
|
setField,
|
||||||
|
text,
|
||||||
|
typeInto,
|
||||||
|
unmountAll,
|
||||||
|
} from './entryEditorKit'
|
||||||
|
import {
|
||||||
|
answerMediaWrite,
|
||||||
|
entryWithThumbnail,
|
||||||
|
focusedName,
|
||||||
|
libraryBody,
|
||||||
|
mediaButton,
|
||||||
|
mediaControl,
|
||||||
|
mediaState,
|
||||||
|
mediaText,
|
||||||
|
mountMedia,
|
||||||
|
pickFromLibrary,
|
||||||
|
summaryOf,
|
||||||
|
} from './entryMediaKit'
|
||||||
|
import { drawsIcon } from './mediaFieldKit'
|
||||||
|
import { customTypes, eventsWithThumbnail, thumbnailField, apiField } from './schemaTestKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(unmountAll)
|
||||||
|
|
||||||
|
const MISSING: MediaSummary = { id: 7, missing: true }
|
||||||
|
const REQUIRED_TEXT = 'Pick an image for Thumbnail.'
|
||||||
|
|
||||||
|
const fieldKeys = () =>
|
||||||
|
[...document.body.querySelectorAll<HTMLElement>('[data-entry-field]')].map(
|
||||||
|
(element) => element.dataset.entryField,
|
||||||
|
)
|
||||||
|
const sentData = (index = 0) => entryWrites[index]!.body.data as Record<string, unknown>
|
||||||
|
const liveText = () => text(fieldWrapper('thumbnail').querySelector('[data-testid="media-live"]'))
|
||||||
|
const errorLine = () => text(fieldWrapper('thumbnail').querySelector('[role="alert"]'))
|
||||||
|
|
||||||
|
async function editTitle() {
|
||||||
|
await typeInto('title', 'Lime plaster workshop, two days')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Where the control sits', () => {
|
||||||
|
it('is the eighth field, after Description, on its own full width row', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7 })
|
||||||
|
|
||||||
|
expect(fieldKeys()).toEqual([
|
||||||
|
'title',
|
||||||
|
'starts_on',
|
||||||
|
'ends_on',
|
||||||
|
'online',
|
||||||
|
'capacity',
|
||||||
|
'category',
|
||||||
|
'description',
|
||||||
|
'thumbnail',
|
||||||
|
])
|
||||||
|
const row = fieldWrapper('thumbnail').parentElement!
|
||||||
|
expect(row.children).toHaveLength(1)
|
||||||
|
expect(row.className).not.toContain('md:grid-cols-2')
|
||||||
|
expect(row.nextElementSibling!.getAttribute('data-testid')).toBe('entry-meta')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never pairs with a boolean, number or date field before it', async () => {
|
||||||
|
const fields = [
|
||||||
|
apiField('Online', 'online', 'boolean'),
|
||||||
|
thumbnailField(),
|
||||||
|
apiField('Title', 'title', 'text', { required: true }),
|
||||||
|
]
|
||||||
|
const type = { ...customTypes[0]!, fields }
|
||||||
|
await mountEditor('/content/events/new', { type: () => type, content: () => [type] })
|
||||||
|
|
||||||
|
const row = fieldWrapper('thumbnail').parentElement!
|
||||||
|
expect(row.children).toHaveLength(1)
|
||||||
|
expect(fieldWrapper('online').parentElement!.children).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the new entry control empty, optional and without a Default tag', async () => {
|
||||||
|
await mountMedia('/content/events/new')
|
||||||
|
|
||||||
|
expect(mediaState()).toBe('empty')
|
||||||
|
expect(mediaText()).toContain('Thumbnail')
|
||||||
|
expect(mediaText()).not.toContain('Default')
|
||||||
|
expect(mediaText()).toContain('One image from your library.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the footer lines after the control on mobile too', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7, width: MOBILE_WIDTH })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fieldWrapper('thumbnail').parentElement!.nextElementSibling!.getAttribute(
|
||||||
|
'data-testid',
|
||||||
|
),
|
||||||
|
).toBe('entry-meta')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('What the control shows and sends', () => {
|
||||||
|
it.each([
|
||||||
|
['null', null, 'none', async () => {}, 'empty', null],
|
||||||
|
['7', 7, undefined, async () => {}, 'filled', 7],
|
||||||
|
['missing 7', 7, MISSING, async () => {}, 'missing', 7],
|
||||||
|
[
|
||||||
|
'7 then 8',
|
||||||
|
7,
|
||||||
|
undefined,
|
||||||
|
async () => {
|
||||||
|
await pickFromLibrary('bathroom-tiles.png')
|
||||||
|
},
|
||||||
|
'filled',
|
||||||
|
8,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'7 then Remove',
|
||||||
|
7,
|
||||||
|
undefined,
|
||||||
|
async () => {
|
||||||
|
mediaButton('Remove image from Thumbnail')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
},
|
||||||
|
'empty',
|
||||||
|
null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'missing 7 then Clear',
|
||||||
|
7,
|
||||||
|
MISSING,
|
||||||
|
async () => {
|
||||||
|
mediaButton('Clear Thumbnail')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
},
|
||||||
|
'empty',
|
||||||
|
null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'null then 8',
|
||||||
|
null,
|
||||||
|
'none',
|
||||||
|
async () => {
|
||||||
|
await pickFromLibrary('bathroom-tiles.png', 'Pick an image for Thumbnail')
|
||||||
|
},
|
||||||
|
'filled',
|
||||||
|
8,
|
||||||
|
],
|
||||||
|
] as const)(
|
||||||
|
'loads %s and shows %s after the save',
|
||||||
|
async (_name, loaded, media, action, shown, sent) => {
|
||||||
|
await mountMedia('/content/events/42', {
|
||||||
|
thumbnail: loaded,
|
||||||
|
media,
|
||||||
|
writeEntry: (write) => {
|
||||||
|
const answered = answerWrite(write)
|
||||||
|
const id = (write.body.data as Record<string, unknown>).thumbnail
|
||||||
|
return typeof id !== 'number'
|
||||||
|
? answered
|
||||||
|
: {
|
||||||
|
...answered,
|
||||||
|
media: {
|
||||||
|
thumbnail:
|
||||||
|
id === 7 && media === MISSING ? MISSING : summaryOf(id),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await action()
|
||||||
|
await editTitle()
|
||||||
|
|
||||||
|
await save()
|
||||||
|
|
||||||
|
expect(entryWrites).toHaveLength(1)
|
||||||
|
expect(sentData().thumbnail).toBe(sent)
|
||||||
|
expect(Object.keys(sentData())).toContain('thumbnail')
|
||||||
|
expect(mediaState()).toBe(shown)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('keeps a missing optional image unchanged and saves', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7, media: MISSING })
|
||||||
|
await editTitle()
|
||||||
|
|
||||||
|
await save()
|
||||||
|
|
||||||
|
expect(sentData().thumbnail).toBe(7)
|
||||||
|
expect(hasText('Entry saved')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The data of the control', () => {
|
||||||
|
it('shows the stored image of the entry with its size line', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7 })
|
||||||
|
|
||||||
|
expect(mediaText()).toContain('lime-plaster-workshop.jpg')
|
||||||
|
expect(mediaText()).toContain('1600 × 900 · 240 KB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the picked library item before any save with no request for it', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7 })
|
||||||
|
|
||||||
|
await pickFromLibrary('bathroom-tiles.png')
|
||||||
|
|
||||||
|
expect(mediaText()).toContain('bathroom-tiles.png')
|
||||||
|
expect(mediaText()).toContain('800 × 600 · 50 KB')
|
||||||
|
expect(
|
||||||
|
mediaControl()
|
||||||
|
.querySelector('[data-testid="media-thumbnail"] img')!
|
||||||
|
.getAttribute('src'),
|
||||||
|
).toBe('/media/bathroom-tiles.png')
|
||||||
|
expect(calls.filter((url) => /^\/v1\/media\/\d+/.test(url))).toEqual([])
|
||||||
|
expect(entryWrites).toHaveLength(0)
|
||||||
|
expect(liveText()).toBe('Image selected: bathroom-tiles.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rebuilds the control from the answered media after the PUT', async () => {
|
||||||
|
await mountMedia('/content/events/42', {
|
||||||
|
thumbnail: 7,
|
||||||
|
writeEntry: (write) => ({
|
||||||
|
...answerWrite(write),
|
||||||
|
media: {
|
||||||
|
thumbnail: { ...summaryOf(8), filename: 'bathroom-tiles-final.png' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
await pickFromLibrary('bathroom-tiles.png')
|
||||||
|
|
||||||
|
await save()
|
||||||
|
|
||||||
|
expect(mediaText()).toContain('bathroom-tiles-final.png')
|
||||||
|
expect(mediaText()).toContain('800 × 600 · 50 KB')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Dirty tracking of a media value', () => {
|
||||||
|
it('reads Saved and disables Save when 8 is picked then 7 again', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7 })
|
||||||
|
|
||||||
|
await pickFromLibrary('bathroom-tiles.png')
|
||||||
|
expect(chip()!.textContent).toContain('Unsaved changes')
|
||||||
|
await pickFromLibrary('lime-plaster-workshop.jpg')
|
||||||
|
|
||||||
|
expect(chip()!.textContent).toContain('Saved')
|
||||||
|
expect(chip()!.textContent).not.toContain('Unsaved')
|
||||||
|
expect((button('Save') as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays Saved when the stored image is picked again from the library', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7 })
|
||||||
|
|
||||||
|
await pickFromLibrary('lime-plaster-workshop.jpg')
|
||||||
|
|
||||||
|
expect(chip()!.textContent).not.toContain('Unsaved')
|
||||||
|
expect((button('Save') as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
expect(liveText()).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prompts the leave guard after picking another image', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7 })
|
||||||
|
await pickFromLibrary('bathroom-tiles.png')
|
||||||
|
|
||||||
|
document.body.querySelector<HTMLAnchorElement>('nav[aria-label="breadcrumb"] a')!.click()
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(hasText('Leave without saving?')).toBe(true))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not prompt after picking and restoring the stored image', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7 })
|
||||||
|
await pickFromLibrary('bathroom-tiles.png')
|
||||||
|
await pickFromLibrary('lime-plaster-workshop.jpg')
|
||||||
|
|
||||||
|
document.body.querySelector<HTMLAnchorElement>('nav[aria-label="breadcrumb"] a')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(hasText('Leave without saving?')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Client validation', () => {
|
||||||
|
it('refuses an empty required field with no request, the error, Not saved and the focus', async () => {
|
||||||
|
const wrapper = await mountMedia('/content/events/new', { required: true })
|
||||||
|
await typeInto('title', 'Open day')
|
||||||
|
await setField(wrapper, 'starts_on', '2026-11-21')
|
||||||
|
|
||||||
|
await save()
|
||||||
|
|
||||||
|
expect(entryWrites).toHaveLength(0)
|
||||||
|
expect(errorLine()).toBe(REQUIRED_TEXT)
|
||||||
|
expect(mediaControl().className).toContain('border-error')
|
||||||
|
expect(chip()!.textContent).toContain('Not saved')
|
||||||
|
expect(focusedName()).toBe('Pick an image for Thumbnail')
|
||||||
|
expect(document.activeElement!.getAttribute('aria-invalid')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a required field whose image is gone, focusing Choose another image', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7, media: MISSING, required: true })
|
||||||
|
await editTitle()
|
||||||
|
|
||||||
|
await save()
|
||||||
|
|
||||||
|
expect(entryWrites).toHaveLength(0)
|
||||||
|
expect(errorLine()).toBe(REQUIRED_TEXT)
|
||||||
|
expect(mediaText()).toContain('Thumbnail is required. Choose another image to save.')
|
||||||
|
expect(drawsIcon(fieldWrapper('thumbnail'), 'circle-alert')).toBe(true)
|
||||||
|
expect(chip()!.textContent).toContain('Not saved')
|
||||||
|
expect(focusedName()).toBe('Choose another image')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the error and the border when an image is picked, validating nothing else', async () => {
|
||||||
|
await mountMedia('/content/events/new', { required: true })
|
||||||
|
await typeInto('title', 'Open day')
|
||||||
|
await save()
|
||||||
|
expect(errorLine()).toBe(REQUIRED_TEXT)
|
||||||
|
expect(hasText('Pick a date for Starts on.')).toBe(true)
|
||||||
|
|
||||||
|
await pickFromLibrary('bathroom-tiles.png', 'Pick an image for Thumbnail')
|
||||||
|
|
||||||
|
expect(errorLine()).toBe('')
|
||||||
|
expect(mediaControl().className).not.toContain('border-error')
|
||||||
|
expect(chip()!.textContent).toContain('Unsaved changes')
|
||||||
|
expect(hasText('Pick a date for Starts on.')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focuses only the first field in schema order that has an error', async () => {
|
||||||
|
await mountMedia('/content/events/new', { required: true })
|
||||||
|
|
||||||
|
await save()
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(fieldWrapper('title').querySelector('input'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focuses the thumbnail control when it is the first error', async () => {
|
||||||
|
const wrapper = await mountMedia('/content/events/new', { required: true })
|
||||||
|
await typeInto('title', 'Open day')
|
||||||
|
await setField(wrapper, 'starts_on', '2026-11-21')
|
||||||
|
|
||||||
|
await save()
|
||||||
|
|
||||||
|
expect(fieldWrapper('thumbnail').contains(document.activeElement)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Saving', () => {
|
||||||
|
it('dims the form to 70 %, the thumbnail to 60 % and disables the buttons', async () => {
|
||||||
|
const answer = pending()
|
||||||
|
await mountMedia('/content/events/42', {
|
||||||
|
thumbnail: 7,
|
||||||
|
writeEntry: () => answer.promise,
|
||||||
|
})
|
||||||
|
await editTitle()
|
||||||
|
|
||||||
|
await save()
|
||||||
|
|
||||||
|
const form = document.body.querySelector('[data-testid="entry-form"]')!
|
||||||
|
expect(form.className).toContain('opacity-70')
|
||||||
|
expect(
|
||||||
|
fieldWrapper('thumbnail')
|
||||||
|
.querySelector('[data-testid="media-thumbnail"]')!
|
||||||
|
.className.split(' '),
|
||||||
|
).toContain('opacity-60')
|
||||||
|
expect(
|
||||||
|
[...fieldWrapper('thumbnail').querySelectorAll<HTMLButtonElement>('button')].every(
|
||||||
|
(candidate) => candidate.disabled,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(hasText('Saving…')).toBe(true)
|
||||||
|
answer.resolve(undefined)
|
||||||
|
await flushPromises()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Server answers for a media field', () => {
|
||||||
|
it('shows 422 required under the control with the error border and the focus on the first button', async () => {
|
||||||
|
await mountMedia('/content/events/42', {
|
||||||
|
thumbnail: 7,
|
||||||
|
writeEntry: failWithBody(422, 'invalid_entry: thumbnail: required'),
|
||||||
|
})
|
||||||
|
await editTitle()
|
||||||
|
|
||||||
|
await save()
|
||||||
|
|
||||||
|
expect(errorLine()).toBe(REQUIRED_TEXT)
|
||||||
|
expect(mediaControl().className).toContain('border-error')
|
||||||
|
expect(chip()!.textContent).toContain('Not saved')
|
||||||
|
expect(fieldWrapper('thumbnail').contains(document.activeElement)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows 422 type under the control and focuses Change image', async () => {
|
||||||
|
await mountMedia('/content/events/42', {
|
||||||
|
thumbnail: 7,
|
||||||
|
writeEntry: failWithBody(422, 'invalid_entry: thumbnail: type'),
|
||||||
|
})
|
||||||
|
await pickFromLibrary('bathroom-tiles.png')
|
||||||
|
|
||||||
|
await save()
|
||||||
|
|
||||||
|
expect(errorLine()).toBe('Thumbnail must be an image from the library.')
|
||||||
|
expect(chip()!.textContent).toContain('Not saved')
|
||||||
|
expect(focusedName()).toBe('Change image')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the changed type notice for an unknown media key and locks the control', async () => {
|
||||||
|
await mountMedia('/content/events/42', {
|
||||||
|
thumbnail: 7,
|
||||||
|
writeEntry: failWithBody(422, 'invalid_entry: thumbnail: unknown_field'),
|
||||||
|
})
|
||||||
|
await editTitle()
|
||||||
|
|
||||||
|
await save()
|
||||||
|
|
||||||
|
expect(hasText('The fields of Events changed')).toBe(true)
|
||||||
|
expect(
|
||||||
|
hasText(
|
||||||
|
"The Thumbnail field was changed after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.",
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(button('Reload form')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
expect(
|
||||||
|
[...fieldWrapper('thumbnail').querySelectorAll<HTMLButtonElement>('button')].every(
|
||||||
|
(candidate) => candidate.disabled,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
fieldWrapper('thumbnail').querySelector('[data-testid="media-thumbnail"]')!.className,
|
||||||
|
).toContain('opacity-60')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the old copy for the other keys', async () => {
|
||||||
|
await mountMedia('/content/events/42', {
|
||||||
|
thumbnail: 7,
|
||||||
|
writeEntry: failWithBody(422, 'invalid_entry: colour: unknown_field'),
|
||||||
|
})
|
||||||
|
await editTitle()
|
||||||
|
|
||||||
|
await save()
|
||||||
|
|
||||||
|
expect(hasText('A field was removed and a new required field, Colour, was added')).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(hasText('The Thumbnail field was changed')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rebuilds the form from a refetched type and entry on Reload form', async () => {
|
||||||
|
let reads = 0
|
||||||
|
await mountMedia('/content/events/42', {
|
||||||
|
thumbnail: 7,
|
||||||
|
writeEntry: failWithBody(422, 'invalid_entry: thumbnail: unknown_field'),
|
||||||
|
entry: () => {
|
||||||
|
reads += 1
|
||||||
|
return reads === 1
|
||||||
|
? entryWithThumbnail(7)
|
||||||
|
: { ...entryWithThumbnail(8), media: { thumbnail: summaryOf(8) } }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await editTitle()
|
||||||
|
await save()
|
||||||
|
|
||||||
|
button('Reload form')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(reads).toBe(2)
|
||||||
|
expect(mediaText()).toContain('bathroom-tiles.png')
|
||||||
|
expect(hasText('The fields of Events changed')).toBe(false)
|
||||||
|
expect(
|
||||||
|
[...fieldWrapper('thumbnail').querySelectorAll<HTMLButtonElement>('button')].some(
|
||||||
|
(candidate) => candidate.disabled,
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The create payload', () => {
|
||||||
|
it('sends thumbnail null among the other keys when the field is left empty', async () => {
|
||||||
|
const wrapper = await mountMedia('/content/events/new')
|
||||||
|
await typeInto('title', 'Open day')
|
||||||
|
await setField(wrapper, 'starts_on', '2026-11-21')
|
||||||
|
|
||||||
|
await save()
|
||||||
|
|
||||||
|
expect(entryWrites).toHaveLength(1)
|
||||||
|
expect(entryWrites[0]).toMatchObject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/content-type/events/entry',
|
||||||
|
})
|
||||||
|
expect(Object.keys(sentData())).toEqual([
|
||||||
|
'title',
|
||||||
|
'starts_on',
|
||||||
|
'ends_on',
|
||||||
|
'online',
|
||||||
|
'capacity',
|
||||||
|
'category',
|
||||||
|
'description',
|
||||||
|
'thumbnail',
|
||||||
|
])
|
||||||
|
expect(sentData().thumbnail).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the picked id as a JSON number', async () => {
|
||||||
|
const wrapper = await mountMedia('/content/events/new')
|
||||||
|
await typeInto('title', 'Open day')
|
||||||
|
await setField(wrapper, 'starts_on', '2026-11-21')
|
||||||
|
await pickFromLibrary('bathroom-tiles.png', 'Pick an image for Thumbnail')
|
||||||
|
|
||||||
|
await save()
|
||||||
|
|
||||||
|
expect(sentData().thumbnail).toBe(8)
|
||||||
|
expect(JSON.stringify(entryWrites[0]!.body)).toContain('"thumbnail":8')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The title of an entry whose first field is media', () => {
|
||||||
|
const type = {
|
||||||
|
...customTypes[0]!,
|
||||||
|
fields: [
|
||||||
|
thumbnailField(),
|
||||||
|
apiField('Title', 'title', 'text', { required: true, options: [] }),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const entry = {
|
||||||
|
...entry42,
|
||||||
|
data: { thumbnail: 7, title: 'Lime plaster workshop' },
|
||||||
|
media: { thumbnail: summaryOf(7) },
|
||||||
|
}
|
||||||
|
const options = {
|
||||||
|
type: () => type,
|
||||||
|
content: () => [type],
|
||||||
|
entry: () => entry,
|
||||||
|
library: () => libraryBody(),
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reads Entry 42 as the page title and never the numeric id', async () => {
|
||||||
|
await mountEditor('/content/events/42', options)
|
||||||
|
|
||||||
|
expect(text(main().querySelector('h1'))).toBe('Entry 42')
|
||||||
|
expect(text(main().querySelector('h1'))).not.toContain('7')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads Entry 42 in the saved toast', async () => {
|
||||||
|
await mountEditor('/content/events/42', {
|
||||||
|
...options,
|
||||||
|
writeEntry: (write) => ({
|
||||||
|
...answerMediaWrite(write),
|
||||||
|
data: { ...entry.data, ...(write.body.data as object) },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
await typeInto('title', 'Lime plaster workshop, two days')
|
||||||
|
|
||||||
|
await save()
|
||||||
|
|
||||||
|
expect(hasText('Entry saved')).toBe(true)
|
||||||
|
expect(hasText('"Entry 42" is up to date.')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads Entry 42 in the leave prompt', async () => {
|
||||||
|
await mountEditor('/content/events/42', options)
|
||||||
|
await typeInto('title', 'Changed')
|
||||||
|
|
||||||
|
document.body.querySelector<HTMLAnchorElement>('nav[aria-label="breadcrumb"] a')!.click()
|
||||||
|
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(hasText('Your changes to "Entry 42" haven\'t been saved.')).toBe(true),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Accessibility of the control in the form', () => {
|
||||||
|
it('draws the control with the image icon and the group label', async () => {
|
||||||
|
await mountMedia('/content/events/new')
|
||||||
|
|
||||||
|
expect(mediaControl().getAttribute('role')).toBe('group')
|
||||||
|
expect(drawsIcon(mediaControl(), 'image')).toBe(true)
|
||||||
|
expect(eventsWithThumbnail().fields).toHaveLength(8)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import {
|
||||||
|
button,
|
||||||
|
calls,
|
||||||
|
chip,
|
||||||
|
failWith,
|
||||||
|
fieldWrapper,
|
||||||
|
hasText,
|
||||||
|
text,
|
||||||
|
unmountAll,
|
||||||
|
} from './entryEditorKit'
|
||||||
|
import {
|
||||||
|
focusedName,
|
||||||
|
libraryBody,
|
||||||
|
mediaButton,
|
||||||
|
mediaState,
|
||||||
|
mediaText,
|
||||||
|
mountMedia,
|
||||||
|
pickFromLibrary,
|
||||||
|
} from './entryMediaKit'
|
||||||
|
import { fieldLibrary } from './mediaFieldKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(unmountAll)
|
||||||
|
|
||||||
|
const dialog = () => document.body.querySelector<HTMLElement>('[role="dialog"]')
|
||||||
|
const tiles = () => [...document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]')]
|
||||||
|
|
||||||
|
async function open(opener = 'Change image') {
|
||||||
|
mediaButton(opener)!.click()
|
||||||
|
await flushPromises()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('The picker opened from the entry editor', () => {
|
||||||
|
it('loads only the library page and never the variants', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7 })
|
||||||
|
|
||||||
|
await open()
|
||||||
|
|
||||||
|
expect(calls.filter((url) => url.startsWith('/v1/media/'))).toEqual([
|
||||||
|
'/v1/media/?page=1&size=25',
|
||||||
|
])
|
||||||
|
expect(calls.some((url) => url.includes('image-variants'))).toBe(false)
|
||||||
|
expect(text(dialog())).toContain('Choose an image')
|
||||||
|
expect(text(dialog())).not.toContain('Alt text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the stored image as Current', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7 })
|
||||||
|
|
||||||
|
await open()
|
||||||
|
|
||||||
|
const current = tiles().filter((tile) =>
|
||||||
|
tile.getAttribute('aria-label')!.endsWith(', Current'),
|
||||||
|
)
|
||||||
|
expect(current.map((tile) => tile.dataset.imageId)).toEqual(['7'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is 640 px wide on desktop and a bottom sheet on mobile', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7 })
|
||||||
|
await open()
|
||||||
|
expect(dialog()!.className).toContain('sm:max-w-160')
|
||||||
|
unmountAll()
|
||||||
|
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7, width: MOBILE_WIDTH })
|
||||||
|
await open()
|
||||||
|
expect(dialog()!.className).toContain('max-sm:!bottom-0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not open while the form is saving', async () => {
|
||||||
|
let release = () => {}
|
||||||
|
await mountMedia('/content/events/42', {
|
||||||
|
thumbnail: 7,
|
||||||
|
writeEntry: () => new Promise((resolve) => (release = () => resolve(undefined))),
|
||||||
|
})
|
||||||
|
const title = fieldWrapper('title').querySelector('input')!
|
||||||
|
title.value = 'Changed'
|
||||||
|
title.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
button('Save')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
mediaButton('Change image')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(dialog()).toBeNull()
|
||||||
|
expect(hasText('Saving…')).toBe(true)
|
||||||
|
release()
|
||||||
|
await flushPromises()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the empty library with the field copy and focuses nothing else', async () => {
|
||||||
|
await mountMedia('/content/events/new', {
|
||||||
|
library: () => ({ ...libraryBody([]), total: 0 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await open('Pick an image for Thumbnail')
|
||||||
|
const library = [...document.body.querySelectorAll<HTMLElement>('[role="tab"]')].find(
|
||||||
|
(tab) => text(tab) === 'Library',
|
||||||
|
)!
|
||||||
|
library.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(text(dialog())).toContain(
|
||||||
|
'Images you upload appear here, ready to use in any article or entry.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the error alert when the library fails and keeps the entry untouched', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7, library: failWith(500) })
|
||||||
|
|
||||||
|
await open()
|
||||||
|
|
||||||
|
expect(text(dialog())).toContain("Couldn't load your library")
|
||||||
|
expect(text(dialog())).toContain('Try again')
|
||||||
|
expect(chip()!.textContent).toContain('Saved')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Focus and announcements in the editor', () => {
|
||||||
|
it('lands on Change image after Use this image', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7 })
|
||||||
|
|
||||||
|
await pickFromLibrary('bathroom-tiles.png')
|
||||||
|
|
||||||
|
expect(focusedName()).toBe('Change image')
|
||||||
|
expect(mediaState()).toBe('filled')
|
||||||
|
expect(mediaText()).toContain('bathroom-tiles.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lands on Pick an image after removing the image', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7 })
|
||||||
|
|
||||||
|
mediaButton('Remove image from Thumbnail')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(focusedName()).toBe('Pick an image for Thumbnail')
|
||||||
|
expect(chip()!.textContent).toContain('Unsaved changes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns to the opener with Cancel', async () => {
|
||||||
|
await mountMedia('/content/events/42', { thumbnail: 7 })
|
||||||
|
const opener = mediaButton('Change image')!
|
||||||
|
opener.focus()
|
||||||
|
await open()
|
||||||
|
;[...dialog()!.querySelectorAll('button')].find((b) => text(b) === 'Cancel')!.click()
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(document.activeElement).toBe(opener))
|
||||||
|
expect(fieldLibrary()).toHaveLength(12)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -388,7 +388,7 @@ describe('Saved fields are locked', () => {
|
|||||||
expect(text(inspector())).toContain("Used in the API, so it can't change.")
|
expect(text(inspector())).toContain("Used in the API, so it can't change.")
|
||||||
expect(inspector()!.querySelector('button[aria-label="Edit key"]')).toBeNull()
|
expect(inspector()!.querySelector('button[aria-label="Edit key"]')).toBeNull()
|
||||||
expect(text(inspector())).toContain("Can't change after saving")
|
expect(text(inspector())).toContain("Can't change after saving")
|
||||||
expect(all('[role="radio"]')).toHaveLength(6)
|
expect(all('[role="radio"]')).toHaveLength(7)
|
||||||
expect(tile('Date').getAttribute('data-state')).toBe('checked')
|
expect(tile('Date').getAttribute('data-state')).toBe('checked')
|
||||||
const others = all<HTMLButtonElement>('[role="radio"]').filter(
|
const others = all<HTMLButtonElement>('[role="radio"]').filter(
|
||||||
(radio) => radio !== tile('Date'),
|
(radio) => radio !== tile('Date'),
|
||||||
@@ -421,7 +421,7 @@ describe('Saved fields are locked', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('Fields added since the last save', () => {
|
describe('Fields added since the last save', () => {
|
||||||
it('selects a new row with New, an Auto key, six selectable tiles and no lock', async () => {
|
it('selects a new row with New, an Auto key, seven selectable tiles and no lock', async () => {
|
||||||
await mountAt('/schema/events')
|
await mountAt('/schema/events')
|
||||||
await click(button('Add field'))
|
await click(button('Add field'))
|
||||||
|
|
||||||
|
|||||||
@@ -209,10 +209,11 @@ const TILES = [
|
|||||||
['Yes or no', 'An on or off switch', 'toggle-left'],
|
['Yes or no', 'An on or off switch', 'toggle-left'],
|
||||||
['Date', 'A calendar day, without a time', 'calendar'],
|
['Date', 'A calendar day, without a time', 'calendar'],
|
||||||
['Choice', 'One option from a list you define', 'list'],
|
['Choice', 'One option from a list you define', 'list'],
|
||||||
|
['Media', 'One image from your media library', 'image'],
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
describe('Type tiles', () => {
|
describe('Type tiles', () => {
|
||||||
it('shows six radio tiles with an icon, a name and a description', async () => {
|
it('shows seven radio tiles with an icon, a name and a description', async () => {
|
||||||
await mountAt('/schema/new')
|
await mountAt('/schema/new')
|
||||||
|
|
||||||
expect(el('[role="radiogroup"]')).not.toBeNull()
|
expect(el('[role="radiogroup"]')).not.toBeNull()
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { addCollection } from '@iconify/vue'
|
||||||
|
import lucide from 'virtual:lucide-subset'
|
||||||
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import {
|
||||||
|
button,
|
||||||
|
eventsWithThumbnail,
|
||||||
|
failWithBody,
|
||||||
|
mountAt,
|
||||||
|
posts,
|
||||||
|
puts,
|
||||||
|
text,
|
||||||
|
thumbnailMount,
|
||||||
|
unmountAll,
|
||||||
|
updatedType,
|
||||||
|
} from './schemaTestKit'
|
||||||
|
import {
|
||||||
|
addField,
|
||||||
|
all,
|
||||||
|
badgesOf,
|
||||||
|
chip,
|
||||||
|
click,
|
||||||
|
el,
|
||||||
|
fieldKeyInput,
|
||||||
|
inspector,
|
||||||
|
inspectorTitle,
|
||||||
|
labelInput,
|
||||||
|
nameInput,
|
||||||
|
pickTile,
|
||||||
|
rowOf,
|
||||||
|
saveButton,
|
||||||
|
selectRow,
|
||||||
|
tile,
|
||||||
|
toggle,
|
||||||
|
typeInto,
|
||||||
|
} from './builderKit'
|
||||||
|
import { drawsIcon } from './mediaFieldKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
addCollection(lucide)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Element.prototype.scrollIntoView = () => undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(unmountAll)
|
||||||
|
|
||||||
|
const TILES = [
|
||||||
|
['Text', 'type', 'Short text, like a title or a name'],
|
||||||
|
['Rich text', 'pilcrow', 'Long text with formatting, written in Markdown'],
|
||||||
|
['Number', 'hash', 'Whole or decimal numbers'],
|
||||||
|
['Yes or no', 'toggle-left', 'An on or off switch'],
|
||||||
|
['Date', 'calendar', 'A calendar day, without a time'],
|
||||||
|
['Choice', 'list', 'One option from a list you define'],
|
||||||
|
['Media', 'image', 'One image from your media library'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const HINT =
|
||||||
|
'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.'
|
||||||
|
|
||||||
|
const grid = () => all('[role="radio"]')[0]!.closest<HTMLElement>('[class*="grid-cols"]')!
|
||||||
|
const savedThumbnail = (rest: object = {}) => thumbnailMount(rest)
|
||||||
|
|
||||||
|
async function addMediaField() {
|
||||||
|
await addField('Thumbnail')
|
||||||
|
await pickTile('Media')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Seven type tiles', () => {
|
||||||
|
it('lists the seven tiles in order, each with its name and description', async () => {
|
||||||
|
await mountAt('/schema/events')
|
||||||
|
await click(button('Add field'))
|
||||||
|
|
||||||
|
const labels = all('[role="radio"]').map((radio) => radio.closest('label')!)
|
||||||
|
|
||||||
|
expect(labels).toHaveLength(7)
|
||||||
|
labels.forEach((label, index) => {
|
||||||
|
const [name, , description] = TILES[index]!
|
||||||
|
expect(text(label)).toBe(`${name} ${description}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws the icon of each tile', async () => {
|
||||||
|
await mountAt('/schema/events')
|
||||||
|
await click(button('Add field'))
|
||||||
|
|
||||||
|
const labels = all('[role="radio"]').map((radio) => radio.closest('label')!)
|
||||||
|
|
||||||
|
labels.forEach((label, index) => {
|
||||||
|
expect(drawsIcon(label, TILES[index]![1])).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has 3 columns in the desktop inspector', async () => {
|
||||||
|
await mountAt('/schema/events')
|
||||||
|
await click(button('Add field'))
|
||||||
|
|
||||||
|
expect(grid().className).toContain('grid-cols-3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has 2 columns in the mobile sheet', async () => {
|
||||||
|
await mountAt('/schema/events', { width: MOBILE_WIDTH })
|
||||||
|
await click(button('Add field'))
|
||||||
|
|
||||||
|
expect(grid().className).toContain('grid-cols-2')
|
||||||
|
expect(grid().className).not.toContain('grid-cols-3')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Adding a media field', () => {
|
||||||
|
it('shows the New badge, an editable Auto key with its help and all seven tiles enabled', async () => {
|
||||||
|
await mountAt('/schema/events')
|
||||||
|
|
||||||
|
await addMediaField()
|
||||||
|
|
||||||
|
expect(badgesOf('Thumbnail')).toContain('New')
|
||||||
|
expect(fieldKeyInput()!.value).toBe('thumbnail')
|
||||||
|
expect(inspector()!.querySelector('button[aria-label="Edit key"]')).not.toBeNull()
|
||||||
|
expect(text(inspector())).toContain(
|
||||||
|
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
|
||||||
|
)
|
||||||
|
expect(all('[role="radio"]')).toHaveLength(7)
|
||||||
|
expect(all<HTMLButtonElement>('[role="radio"]').every((radio) => !radio.disabled)).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(tile('Media').getAttribute('data-state')).toBe('checked')
|
||||||
|
expect(inspector()!.querySelector('[data-testid="slug-lock"]')).toBeNull()
|
||||||
|
expect(text(inspector())).not.toContain("Can't change after saving")
|
||||||
|
expect(chip()).toBe('Unsaved changes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws the row as a grip, a 28 px square with the image icon, the label, the key and New', async () => {
|
||||||
|
await mountAt('/schema/events')
|
||||||
|
|
||||||
|
await addMediaField()
|
||||||
|
|
||||||
|
const row = rowOf('Thumbnail')!
|
||||||
|
const square = row.querySelector('[data-field-row] > span')!
|
||||||
|
expect(row.querySelector('[data-grip]')).not.toBeNull()
|
||||||
|
expect(square.className).toContain('size-7')
|
||||||
|
expect(drawsIcon(square, 'image')).toBe(true)
|
||||||
|
expect(text(row.querySelector('[data-testid="field-row-label"]'))).toBe('Thumbnail')
|
||||||
|
expect(text(row.querySelector('[data-testid="field-row-key"]'))).toBe('thumbnail')
|
||||||
|
expect(row.querySelector('[data-testid="field-row-key"]')!.className).toContain('font-mono')
|
||||||
|
expect(badgesOf('Thumbnail')).toContain('New')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps label, key and Required and resets the rest changing a new row to Media and back', async () => {
|
||||||
|
await mountAt('/schema/events')
|
||||||
|
await addField('Thumbnail')
|
||||||
|
await toggle('Required')
|
||||||
|
await typeInto(el<HTMLInputElement>('input[name="max"]'), '80')
|
||||||
|
|
||||||
|
await pickTile('Media')
|
||||||
|
|
||||||
|
expect(labelInput()!.value).toBe('Thumbnail')
|
||||||
|
expect(fieldKeyInput()!.value).toBe('thumbnail')
|
||||||
|
expect(text(inspector())).toContain(HINT)
|
||||||
|
expect(el('input[name="max"]')).toBeNull()
|
||||||
|
|
||||||
|
await pickTile('Number')
|
||||||
|
|
||||||
|
expect(text(inspector())).not.toContain(HINT)
|
||||||
|
expect(el<HTMLInputElement>('input[name="max"]')!.value).toBe('')
|
||||||
|
expect(el<HTMLInputElement>('input[name="min"]')!.value).toBe('')
|
||||||
|
expect(el<HTMLInputElement>('input[name="default"]')!.value).toBe('')
|
||||||
|
expect(labelInput()!.value).toBe('Thumbnail')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The inspector of a saved media field', () => {
|
||||||
|
it('reads Field: Thumbnail and shows only what applies, in order', async () => {
|
||||||
|
await mountAt('/schema/events', savedThumbnail())
|
||||||
|
|
||||||
|
await selectRow('Thumbnail')
|
||||||
|
|
||||||
|
expect(inspectorTitle()).toBe('Field: Thumbnail')
|
||||||
|
const inside = text(inspector())
|
||||||
|
const order = [
|
||||||
|
'Label',
|
||||||
|
'Key',
|
||||||
|
"Used in the API, so it can't change.",
|
||||||
|
'Type',
|
||||||
|
"Can't change after saving",
|
||||||
|
'Required',
|
||||||
|
"An entry can't be saved with this field empty.",
|
||||||
|
HINT,
|
||||||
|
].map((part) => inside.indexOf(part))
|
||||||
|
expect(order.every((index) => index >= 0)).toBe(true)
|
||||||
|
expect([...order].sort((a, b) => a - b)).toEqual(order)
|
||||||
|
expect(labelInput()!.value).toBe('Thumbnail')
|
||||||
|
expect(fieldKeyInput()!.value).toBe('thumbnail')
|
||||||
|
expect(fieldKeyInput()!.readOnly).toBe(true)
|
||||||
|
expect(inspector()!.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects Media, dims the six other tiles and makes them unselectable', async () => {
|
||||||
|
await mountAt('/schema/events', savedThumbnail())
|
||||||
|
|
||||||
|
await selectRow('Thumbnail')
|
||||||
|
|
||||||
|
expect(all('[role="radio"]')).toHaveLength(7)
|
||||||
|
expect(tile('Media').getAttribute('data-state')).toBe('checked')
|
||||||
|
const others = all<HTMLButtonElement>('[role="radio"]').filter(
|
||||||
|
(radio) => radio !== tile('Media'),
|
||||||
|
)
|
||||||
|
expect(others).toHaveLength(6)
|
||||||
|
expect(others.every((radio) => radio.disabled || radio.hasAttribute('data-disabled'))).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
others.every((radio) => radio.closest('label')!.innerHTML.includes('opacity-40')),
|
||||||
|
).toBe(true)
|
||||||
|
await pickTile('Number')
|
||||||
|
expect(tile('Media').getAttribute('data-state')).toBe('checked')
|
||||||
|
expect(chip()).toBe('Saved')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws the hint as an info block with an info icon', async () => {
|
||||||
|
await mountAt('/schema/events', savedThumbnail())
|
||||||
|
|
||||||
|
await selectRow('Thumbnail')
|
||||||
|
|
||||||
|
const hint = el('[data-testid="builder-hint"]')!
|
||||||
|
expect(text(hint)).toBe(HINT)
|
||||||
|
expect(hint.className).toContain('bg-(--rc-info-soft)')
|
||||||
|
expect(drawsIcon(hint, 'info')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render the options that do not apply', async () => {
|
||||||
|
await mountAt('/schema/events', savedThumbnail())
|
||||||
|
|
||||||
|
await selectRow('Thumbnail')
|
||||||
|
|
||||||
|
const inside = text(inspector())
|
||||||
|
expect(inside).not.toContain('Unique values')
|
||||||
|
expect(inside).not.toContain('Default value')
|
||||||
|
expect(inside).not.toContain('Limits')
|
||||||
|
expect(inside).not.toContain('Shortest')
|
||||||
|
expect(inside).not.toContain('Choices')
|
||||||
|
expect(el('input[name="min"]')).toBeNull()
|
||||||
|
expect(el('input[name="max"]')).toBeNull()
|
||||||
|
expect(el('input[name="default"]')).toBeNull()
|
||||||
|
expect(el('[data-testid="choice-row"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still shows the disabled Unique switch on a Yes or no field', async () => {
|
||||||
|
await mountAt('/schema/events', savedThumbnail())
|
||||||
|
|
||||||
|
await selectRow('Online')
|
||||||
|
|
||||||
|
expect(text(inspector())).toContain('Unique values')
|
||||||
|
expect(text(inspector())).toContain('Limits')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('A required media field', () => {
|
||||||
|
it('carries the Required marker on its row and the switch on', async () => {
|
||||||
|
await mountAt('/schema/events', savedThumbnail({ required: true }))
|
||||||
|
|
||||||
|
await selectRow('Thumbnail')
|
||||||
|
|
||||||
|
const marker = rowOf('Thumbnail')!.querySelector('[aria-label="Required"]')!
|
||||||
|
expect(text(marker)).toBe('R')
|
||||||
|
expect(
|
||||||
|
el('[data-testid="field-inspector"] [role="switch"][aria-checked="true"]'),
|
||||||
|
).not.toBeNull()
|
||||||
|
expect(text(inspector())).toContain("An entry can't be saved with this field empty.")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no marker on an optional media field', async () => {
|
||||||
|
await mountAt('/schema/events', savedThumbnail())
|
||||||
|
|
||||||
|
expect(rowOf('Thumbnail')!.querySelector('[aria-label="Required"]')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The mobile sheet of a media field', () => {
|
||||||
|
it('opens the 92 % sheet titled Field: Thumbnail with the inspector, two columns and Done', async () => {
|
||||||
|
await mountAt('/schema/events', { ...savedThumbnail(), width: MOBILE_WIDTH })
|
||||||
|
|
||||||
|
await click(rowOf('Thumbnail')!.querySelector('[data-field-row]'))
|
||||||
|
|
||||||
|
expect(text(el('[data-testid="field-sheet-title"]'))).toBe('Field: Thumbnail')
|
||||||
|
expect(text(el('[role="dialog"]'))).toContain(HINT)
|
||||||
|
expect(grid().className).toContain('grid-cols-2')
|
||||||
|
expect(button('Done')).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
expect(el('[role="dialog"]')!.className).toContain('h-11/12')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Media in the API preview', () => {
|
||||||
|
it('shows thumbnail: null and no example text', async () => {
|
||||||
|
await mountAt('/schema/new')
|
||||||
|
await typeInto(nameInput(), 'Events')
|
||||||
|
await addMediaField()
|
||||||
|
await click(el('[data-testid="api-preview-toggle"]'))
|
||||||
|
|
||||||
|
const data = JSON.parse(el('[data-testid="api-json"]')!.textContent!).data
|
||||||
|
expect(data.thumbnail).toBeNull()
|
||||||
|
expect(Object.keys(data)).toEqual(['title', 'thumbnail'])
|
||||||
|
expect(el('[data-testid="api-json"]')!.textContent).not.toContain(
|
||||||
|
'Example text"\n "thumbnail',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Saving a media field', () => {
|
||||||
|
it('sends the field in the exact shape in the PUT body', async () => {
|
||||||
|
await mountAt('/schema/events', {
|
||||||
|
entries: () => ({ items: [], total: 0, page: 1, size: 1 }),
|
||||||
|
})
|
||||||
|
await addMediaField()
|
||||||
|
|
||||||
|
await click(saveButton())
|
||||||
|
|
||||||
|
expect(puts).toHaveLength(1)
|
||||||
|
const fields = (puts[0]!.body as { fields: unknown[] }).fields
|
||||||
|
expect(fields[7]).toEqual({
|
||||||
|
key: 'thumbnail',
|
||||||
|
label: 'Thumbnail',
|
||||||
|
type: 'media',
|
||||||
|
required: false,
|
||||||
|
unique: false,
|
||||||
|
default: null,
|
||||||
|
min: null,
|
||||||
|
max: null,
|
||||||
|
options: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the field in the POST body of a new type', async () => {
|
||||||
|
await mountAt('/schema/new')
|
||||||
|
await typeInto(nameInput(), 'Gallery')
|
||||||
|
await selectRow('Title')
|
||||||
|
await addMediaField()
|
||||||
|
await toggle('Required')
|
||||||
|
|
||||||
|
await click(saveButton())
|
||||||
|
|
||||||
|
expect(posts).toHaveLength(1)
|
||||||
|
const fields = (posts[0]!.body as { fields: Record<string, unknown>[] }).fields
|
||||||
|
expect(fields[1]).toEqual({
|
||||||
|
key: 'thumbnail',
|
||||||
|
label: 'Thumbnail',
|
||||||
|
type: 'media',
|
||||||
|
required: true,
|
||||||
|
unique: false,
|
||||||
|
default: null,
|
||||||
|
min: null,
|
||||||
|
max: null,
|
||||||
|
options: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the existing invalid field failure on 422 invalid_field and keeps the edits', async () => {
|
||||||
|
await mountAt('/schema/events', {
|
||||||
|
entries: () => ({ items: [], total: 0, page: 1, size: 1 }),
|
||||||
|
update: failWithBody(422, 'invalid_field'),
|
||||||
|
})
|
||||||
|
await addMediaField()
|
||||||
|
|
||||||
|
await click(saveButton())
|
||||||
|
|
||||||
|
expect(text(document.body)).toContain('Check the fields marked in the list.')
|
||||||
|
expect(rowOf('Thumbnail')).toBeInstanceOf(HTMLElement)
|
||||||
|
expect(chip()).toBe('Unsaved changes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('runs the existing refusal flow on a stale 409 schema_incompatible', async () => {
|
||||||
|
await mountAt('/schema/events', {
|
||||||
|
entries: () => ({ items: [], total: 0, page: 1, size: 1 }),
|
||||||
|
update: failWithBody(409, 'schema_incompatible'),
|
||||||
|
})
|
||||||
|
await addMediaField()
|
||||||
|
await toggle('Required')
|
||||||
|
|
||||||
|
await click(saveButton())
|
||||||
|
|
||||||
|
expect(text(document.body)).toContain('Nothing was changed')
|
||||||
|
expect(rowOf('Thumbnail')).toBeInstanceOf(HTMLElement)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips a saved media field with no change after a save', async () => {
|
||||||
|
await mountAt('/schema/events', {
|
||||||
|
...savedThumbnail(),
|
||||||
|
entries: () => ({ items: [], total: 0, page: 1, size: 1 }),
|
||||||
|
update: (body) =>
|
||||||
|
updatedType('events', { ...body, fields: eventsWithThumbnail().fields }),
|
||||||
|
})
|
||||||
|
await selectRow('Thumbnail')
|
||||||
|
await typeInto(labelInput(), 'Cover')
|
||||||
|
await click(saveButton())
|
||||||
|
|
||||||
|
expect(puts).toHaveLength(1)
|
||||||
|
const sent = (puts[0]!.body as { fields: { label: string; type: string }[] }).fields[7]!
|
||||||
|
expect(sent.type).toBe('media')
|
||||||
|
expect(sent.label).toBe('Cover')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { addCollection } from '@iconify/vue'
|
||||||
|
import lucide from 'virtual:lucide-subset'
|
||||||
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import {
|
||||||
|
button,
|
||||||
|
entryPage,
|
||||||
|
failWithBody,
|
||||||
|
mountAt,
|
||||||
|
puts,
|
||||||
|
text,
|
||||||
|
thumbnailMount,
|
||||||
|
unmountAll,
|
||||||
|
} from './schemaTestKit'
|
||||||
|
import {
|
||||||
|
addField,
|
||||||
|
click,
|
||||||
|
dialog,
|
||||||
|
el,
|
||||||
|
pickTile,
|
||||||
|
saveButton,
|
||||||
|
selectRow,
|
||||||
|
setMax,
|
||||||
|
toggle,
|
||||||
|
all,
|
||||||
|
} from './builderKit'
|
||||||
|
import { drawsIcon } from './mediaFieldKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
addCollection(lucide)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Element.prototype.scrollIntoView = () => undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
await unmountAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
const REFUSED_TEXT =
|
||||||
|
"Media, required. A media field can't have a default, so the 12 existing entries would have no image. Make Thumbnail optional to add it."
|
||||||
|
|
||||||
|
const withEntries = (total: number) => ({ entries: () => entryPage(total) })
|
||||||
|
|
||||||
|
async function addThumbnail(required: boolean) {
|
||||||
|
await addField('Thumbnail')
|
||||||
|
await pickTile('Media')
|
||||||
|
if (required) {
|
||||||
|
await toggle('Required')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reviewWith(required: boolean, total = 12, extra: object = {}) {
|
||||||
|
await mountAt('/schema/events', { ...withEntries(total), ...extra })
|
||||||
|
await addThumbnail(required)
|
||||||
|
await click(saveButton())
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowText = () => text(el('[data-testid="change-row"]'))
|
||||||
|
const apply = () => button('Apply changes') as HTMLButtonElement
|
||||||
|
const back = () => button('Back to editing') as HTMLButtonElement
|
||||||
|
|
||||||
|
describe('A required media field added to a type with entries', () => {
|
||||||
|
it('opens the review without sending the PUT', async () => {
|
||||||
|
await reviewWith(true)
|
||||||
|
|
||||||
|
expect(puts).toHaveLength(0)
|
||||||
|
expect(text(dialog())).toContain('Review changes to Events')
|
||||||
|
expect(text(dialog())).toContain('Apply them to save the content type.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the summary, the entry count, the row, the flag and the note', async () => {
|
||||||
|
await reviewWith(true)
|
||||||
|
|
||||||
|
expect(text(el('[data-testid="review-summary"]'))).toBe('Add 1 field')
|
||||||
|
expect(text(dialog())).toContain(
|
||||||
|
'Events has 12 entries. Each one is checked against the new fields when you apply.',
|
||||||
|
)
|
||||||
|
expect(text(el('[data-testid="change-title"]'))).toBe('Add Thumbnail')
|
||||||
|
expect(text(el('[data-testid="change-flag"]'))).toBe("Can't be applied")
|
||||||
|
expect(text(el('[data-testid="change-text"]'))).toBe(REFUSED_TEXT)
|
||||||
|
expect(text(dialog())).toContain('Back to editing keeps your other changes.')
|
||||||
|
expect(text(dialog())).not.toContain('Flagged changes can be refused')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws the flag as text with a warning icon, not only a colour', async () => {
|
||||||
|
await reviewWith(true)
|
||||||
|
|
||||||
|
const flag = el('[data-testid="change-flag"]')!
|
||||||
|
expect(text(flag)).toBe("Can't be applied")
|
||||||
|
expect(drawsIcon(flag, 'triangle-alert')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps Back to editing enabled and announces Apply changes as disabled', async () => {
|
||||||
|
await reviewWith(true)
|
||||||
|
|
||||||
|
expect(back().disabled).toBe(false)
|
||||||
|
expect(apply().getAttribute('aria-disabled')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends nothing when Apply changes is pressed', async () => {
|
||||||
|
await reviewWith(true)
|
||||||
|
|
||||||
|
await click(apply())
|
||||||
|
|
||||||
|
expect(puts).toHaveLength(0)
|
||||||
|
expect(text(dialog())).toContain('Review changes to Events')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays disabled whatever the connection', async () => {
|
||||||
|
await reviewWith(true)
|
||||||
|
|
||||||
|
expect(apply().getAttribute('aria-disabled')).toBe('true')
|
||||||
|
window.dispatchEvent(new Event('offline'))
|
||||||
|
await flushPromises()
|
||||||
|
expect(apply().getAttribute('aria-disabled')).toBe('true')
|
||||||
|
window.dispatchEvent(new Event('online'))
|
||||||
|
await flushPromises()
|
||||||
|
expect(apply().getAttribute('aria-disabled')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the bottom sheet on mobile', async () => {
|
||||||
|
await reviewWith(true, 12, { width: MOBILE_WIDTH })
|
||||||
|
|
||||||
|
expect(dialog()!.className).toContain('max-sm:')
|
||||||
|
expect(text(el('[data-testid="change-flag"]'))).toBe("Can't be applied")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Back to editing after the refusal', () => {
|
||||||
|
it.each([
|
||||||
|
['Back to editing', async () => click(back())],
|
||||||
|
[
|
||||||
|
'Esc',
|
||||||
|
async () => {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||||
|
)
|
||||||
|
await flushPromises()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])('closes with %s, keeps every edit and sends nothing', async (_name, close) => {
|
||||||
|
await reviewWith(true)
|
||||||
|
|
||||||
|
await close()
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||||
|
expect(puts).toHaveLength(0)
|
||||||
|
expect(text(el('#dashboard-content'))).toContain('Thumbnail')
|
||||||
|
expect(text(el('[data-testid="builder-chip"]'))).toBe('Unsaved changes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the clean row with Apply enabled once Required is turned off', async () => {
|
||||||
|
await reviewWith(true)
|
||||||
|
await click(back())
|
||||||
|
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||||
|
|
||||||
|
await toggle('Required')
|
||||||
|
await click(saveButton())
|
||||||
|
|
||||||
|
expect(text(el('[data-testid="change-text"]'))).toBe(
|
||||||
|
'Media, optional. 12 entries are unaffected.',
|
||||||
|
)
|
||||||
|
expect(el('[data-testid="change-flag"]')).toBeNull()
|
||||||
|
expect(apply().getAttribute('aria-disabled')).toBeNull()
|
||||||
|
expect(apply().disabled).toBe(false)
|
||||||
|
expect(text(dialog())).not.toContain('Flagged changes can be refused')
|
||||||
|
expect(text(dialog())).not.toContain('Back to editing keeps your other changes.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The clean row', () => {
|
||||||
|
it.each([
|
||||||
|
[12, 'Media, optional. 12 entries are unaffected.'],
|
||||||
|
[1, 'Media, optional. 1 entry is unaffected.'],
|
||||||
|
])('reads the row for %i entries and enables Apply changes', async (total, line) => {
|
||||||
|
await reviewWith(false, total)
|
||||||
|
|
||||||
|
expect(rowText()).toContain('Add Thumbnail')
|
||||||
|
expect(text(el('[data-testid="change-text"]'))).toBe(line)
|
||||||
|
expect(el('[data-testid="change-flag"]')).toBeNull()
|
||||||
|
expect(apply().disabled).toBe(false)
|
||||||
|
expect(apply().getAttribute('aria-disabled')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies with the PUT and the media field', async () => {
|
||||||
|
await reviewWith(false)
|
||||||
|
|
||||||
|
await click(apply())
|
||||||
|
|
||||||
|
expect(puts).toHaveLength(1)
|
||||||
|
expect((puts[0]!.body as { fields: { type: string }[] }).fields[7]!.type).toBe('media')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays disabled while offline, as before', async () => {
|
||||||
|
await reviewWith(false)
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event('offline'))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(apply().disabled).toBe(true)
|
||||||
|
window.dispatchEvent(new Event('online'))
|
||||||
|
await flushPromises()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('When the review is not refused', () => {
|
||||||
|
it('sends the PUT at once for a type with no entries', async () => {
|
||||||
|
await mountAt('/schema/events', withEntries(0))
|
||||||
|
await addThumbnail(true)
|
||||||
|
|
||||||
|
await click(saveButton())
|
||||||
|
|
||||||
|
expect(dialog()).toBeNull()
|
||||||
|
expect(puts).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags making a saved optional media field required as one that may not fit and keeps Apply enabled', async () => {
|
||||||
|
await mountAt('/schema/events', { ...thumbnailMount(), ...withEntries(12) })
|
||||||
|
await selectRow('Thumbnail')
|
||||||
|
await toggle('Required')
|
||||||
|
|
||||||
|
await click(saveButton())
|
||||||
|
|
||||||
|
expect(text(el('[data-testid="change-title"]'))).toBe('Change Thumbnail')
|
||||||
|
expect(text(el('[data-testid="change-text"]'))).toBe('Now required.')
|
||||||
|
expect(text(el('[data-testid="change-flag"]'))).toBe('Entries may not fit')
|
||||||
|
expect(apply().disabled).toBe(false)
|
||||||
|
expect(apply().getAttribute('aria-disabled')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists both rows and disables Apply for a new required Thumbnail with a Capacity change', async () => {
|
||||||
|
await mountAt('/schema/events', withEntries(12))
|
||||||
|
await setMax('2000')
|
||||||
|
await addThumbnail(true)
|
||||||
|
|
||||||
|
await click(saveButton())
|
||||||
|
|
||||||
|
const titles = all('[data-testid="change-title"]').map(text)
|
||||||
|
expect(titles).toEqual(['Add Thumbnail', 'Change Capacity'])
|
||||||
|
expect(apply().getAttribute('aria-disabled')).toBe('true')
|
||||||
|
expect(text(dialog())).toContain('Back to editing keeps your other changes.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The server refuses anyway', () => {
|
||||||
|
it('runs the existing Nothing was changed flow on a stale 409', async () => {
|
||||||
|
await mountAt('/schema/events', {
|
||||||
|
...thumbnailMount(),
|
||||||
|
...withEntries(12),
|
||||||
|
update: failWithBody(409, 'schema_incompatible'),
|
||||||
|
})
|
||||||
|
await selectRow('Thumbnail')
|
||||||
|
await toggle('Required')
|
||||||
|
await click(saveButton())
|
||||||
|
|
||||||
|
await click(apply())
|
||||||
|
|
||||||
|
expect(puts).toHaveLength(1)
|
||||||
|
expect(text(dialog())).toContain('Nothing was changed')
|
||||||
|
expect(text(dialog())).not.toContain('Review changes to Events')
|
||||||
|
expect(text(dialog())).toContain('Possible cause')
|
||||||
|
expect(text(dialog())).toContain('Change Thumbnail')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The flagged changes note of existing reviews', () => {
|
||||||
|
const NOTE = "Flagged changes can be refused if existing entries don't fit."
|
||||||
|
|
||||||
|
it('still shows for an unflagged text field added to a type with entries', async () => {
|
||||||
|
await mountAt('/schema/events', withEntries(12))
|
||||||
|
await addField('Speaker')
|
||||||
|
|
||||||
|
await click(saveButton())
|
||||||
|
|
||||||
|
expect(text(dialog())).toContain(NOTE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still shows for a flagged change', async () => {
|
||||||
|
await mountAt('/schema/events', withEntries(12))
|
||||||
|
await setMax('2000')
|
||||||
|
|
||||||
|
await click(saveButton())
|
||||||
|
|
||||||
|
expect(text(dialog())).toContain(NOTE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still shows when an optional media field comes with another change', async () => {
|
||||||
|
await mountAt('/schema/events', withEntries(12))
|
||||||
|
await addThumbnail(false)
|
||||||
|
await addField('Speaker')
|
||||||
|
|
||||||
|
await click(saveButton())
|
||||||
|
|
||||||
|
expect(text(dialog())).toContain(NOTE)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -160,7 +160,7 @@ describe('New type, desktop (design state 1)', () => {
|
|||||||
expect(copy).toContain(
|
expect(copy).toContain(
|
||||||
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
|
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
|
||||||
)
|
)
|
||||||
expect(all('[role="radio"]')).toHaveLength(6)
|
expect(all('[role="radio"]')).toHaveLength(7)
|
||||||
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
|
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
|
||||||
'Text',
|
'Text',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import type { MediaItem } from '@/composables/useMedia'
|
||||||
|
import { imageOfItem, type MediaSummary } from '@/lib/mediaField'
|
||||||
|
import {
|
||||||
|
answerWrite,
|
||||||
|
entry42,
|
||||||
|
fieldWrapper,
|
||||||
|
lookupNames,
|
||||||
|
mountEditor,
|
||||||
|
text,
|
||||||
|
} from './entryEditorKit'
|
||||||
|
import { fieldLibrary } from './mediaFieldKit'
|
||||||
|
import { eventsWithThumbnail } from './schemaTestKit'
|
||||||
|
import type { EntryWrite, SchemaMount } from './schemaTestKit'
|
||||||
|
|
||||||
|
export const LIBRARY_COUNTS = { all: 48, unused: 2, duplicates: 2 }
|
||||||
|
|
||||||
|
export function libraryBody(items: MediaItem[] = fieldLibrary()) {
|
||||||
|
return { items, total: 48, page: 1, size: 25, counts: LIBRARY_COUNTS }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summaryOf(id: number): MediaSummary {
|
||||||
|
const item = fieldLibrary().find((candidate) => candidate.id === id)!
|
||||||
|
return imageOfItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entryWithThumbnail(thumbnail: number | null, media?: MediaSummary | 'none') {
|
||||||
|
const shown =
|
||||||
|
media === 'none' || thumbnail === null ? undefined : (media ?? summaryOf(thumbnail))
|
||||||
|
return {
|
||||||
|
...entry42,
|
||||||
|
data: { ...entry42.data, thumbnail },
|
||||||
|
...(shown ? { media: { thumbnail: shown } } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function answerMediaWrite(write: EntryWrite) {
|
||||||
|
const answered = answerWrite(write)
|
||||||
|
const id = (write.body.data as Record<string, unknown>).thumbnail
|
||||||
|
return typeof id === 'number' ? { ...answered, media: { thumbnail: summaryOf(id) } } : answered
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountMedia(
|
||||||
|
path: string,
|
||||||
|
options: SchemaMount & {
|
||||||
|
required?: boolean
|
||||||
|
thumbnail?: number | null
|
||||||
|
media?: MediaSummary | 'none'
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const { required, thumbnail = null, media, ...rest } = options
|
||||||
|
const type = eventsWithThumbnail({ required: required ?? false })
|
||||||
|
return mountEditor(path, {
|
||||||
|
type: () => type,
|
||||||
|
content: () => [type],
|
||||||
|
entry: () => entryWithThumbnail(thumbnail, media),
|
||||||
|
writeEntry: answerMediaWrite,
|
||||||
|
library: () => libraryBody(),
|
||||||
|
lookup: lookupNames,
|
||||||
|
...rest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaControl() {
|
||||||
|
return fieldWrapper('thumbnail').querySelector<HTMLElement>('[data-testid="media-control"]')!
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaState() {
|
||||||
|
const box = mediaControl()
|
||||||
|
if (box.querySelector('[data-testid="media-empty"]')) {
|
||||||
|
return 'empty'
|
||||||
|
}
|
||||||
|
return box.querySelector('[data-testid="media-filled"]') ? 'filled' : 'missing'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaText() {
|
||||||
|
return text(fieldWrapper('thumbnail'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaButton(name: string) {
|
||||||
|
return [...fieldWrapper('thumbnail').querySelectorAll<HTMLButtonElement>('button')].find(
|
||||||
|
(button) => (button.getAttribute('aria-label') ?? text(button)) === name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focusedName() {
|
||||||
|
const active = document.activeElement
|
||||||
|
return active?.getAttribute('aria-label') ?? text(active)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickFromLibrary(filename: string, opener = 'Change image') {
|
||||||
|
mediaButton(opener)!.click()
|
||||||
|
await flushPromises()
|
||||||
|
await flushPromises()
|
||||||
|
const tile = [...document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]')].find(
|
||||||
|
(element) => element.getAttribute('aria-label')!.startsWith(`${filename},`),
|
||||||
|
)!
|
||||||
|
tile.click()
|
||||||
|
await flushPromises()
|
||||||
|
;[...document.body.querySelectorAll('button')]
|
||||||
|
.find((button) => text(button) === 'Use this image')!
|
||||||
|
.click()
|
||||||
|
await flushPromises()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import lucide from 'virtual:lucide-subset'
|
||||||
|
import type { MediaItem } from '@/composables/useMedia'
|
||||||
|
import { fixture } from './mediaTestKit'
|
||||||
|
|
||||||
|
const SHAPES = 'path, circle, rect, line, polyline, polygon, ellipse'
|
||||||
|
|
||||||
|
function shapesOf(root: Element) {
|
||||||
|
return [...root.querySelectorAll(SHAPES)]
|
||||||
|
.map((shape) =>
|
||||||
|
[
|
||||||
|
shape.tagName.toLowerCase(),
|
||||||
|
...[...shape.attributes].map((a) => `${a.name}=${a.value}`),
|
||||||
|
]
|
||||||
|
.sort()
|
||||||
|
.join(' '),
|
||||||
|
)
|
||||||
|
.join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconShapes(name: string) {
|
||||||
|
const holder = document.createElement('div')
|
||||||
|
holder.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg">${lucide.icons[name]!.body}</svg>`
|
||||||
|
return shapesOf(holder.firstElementChild!)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawsIcon(container: Element | null | undefined, name: string) {
|
||||||
|
const wanted = iconShapes(name)
|
||||||
|
return [...(container?.querySelectorAll('svg') ?? [])].some((svg) => shapesOf(svg) === wanted)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fieldLibrary(): MediaItem[] {
|
||||||
|
return fixture().map((item) => {
|
||||||
|
if (item.id === 7) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
filename: 'lime-plaster-workshop.jpg',
|
||||||
|
url: '/media/lime-plaster-workshop.jpg',
|
||||||
|
width: 1600,
|
||||||
|
height: 900,
|
||||||
|
size: 245760,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.id === 8) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
filename: 'bathroom-tiles.png',
|
||||||
|
url: '/media/bathroom-tiles.png',
|
||||||
|
content_type: 'image/png',
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
size: 51200,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
|||||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||||
import { eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
|
import { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
|
||||||
|
|
||||||
export { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
|
export { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
|
||||||
|
|
||||||
@@ -44,6 +44,21 @@ export const customTypes = [
|
|||||||
},
|
},
|
||||||
].map((type) => ({ ...type, created_at: '2026-09-01T09:30:00Z' }))
|
].map((type) => ({ ...type, created_at: '2026-09-01T09:30:00Z' }))
|
||||||
|
|
||||||
|
export function thumbnailField(rest: object = {}) {
|
||||||
|
return apiField('Thumbnail', 'thumbnail', 'media', { options: [], ...rest })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventsWithThumbnail(rest: object = {}) {
|
||||||
|
return { ...customTypes[0]!, fields: [...eventsFields, thumbnailField(rest)] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function thumbnailMount(rest: object = {}) {
|
||||||
|
return {
|
||||||
|
content: () => [eventsWithThumbnail(rest), customTypes[1]!, customTypes[2]!],
|
||||||
|
type: (key: string) => (key === 'events' ? eventsWithThumbnail(rest) : customTypes[1]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function articleField(label: string, key: string, type: string, rest: object = {}) {
|
function articleField(label: string, key: string, type: string, rest: object = {}) {
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
@@ -122,6 +137,14 @@ export interface PostedRequest {
|
|||||||
|
|
||||||
export const entryCounts: Record<string, number> = { events: 37, recipes: 0 }
|
export const entryCounts: Record<string, number> = { events: 37, recipes: 0 }
|
||||||
|
|
||||||
|
export const emptyLibrary = {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
size: 25,
|
||||||
|
counts: { all: 0, unused: 0, duplicates: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
export function entryPage(total: number) {
|
export function entryPage(total: number) {
|
||||||
return { items: [], total, page: 1, size: 1 }
|
return { items: [], total, page: 1, size: 1 }
|
||||||
}
|
}
|
||||||
@@ -157,6 +180,7 @@ export interface SchemaMount {
|
|||||||
type?: (key: string) => unknown
|
type?: (key: string) => unknown
|
||||||
list?: (key: string, query: URLSearchParams) => unknown
|
list?: (key: string, query: URLSearchParams) => unknown
|
||||||
lookup?: (ids: number[]) => unknown
|
lookup?: (ids: number[]) => unknown
|
||||||
|
library?: (query: URLSearchParams) => unknown
|
||||||
before?: () => void
|
before?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +250,10 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
|||||||
return (options.create ?? (() => createdType(body)))(body)
|
return (options.create ?? (() => createdType(body)))(body)
|
||||||
}
|
}
|
||||||
calls.push(url)
|
calls.push(url)
|
||||||
|
const library = url.match(/^\/v1\/media\/\?(.*)$/)
|
||||||
|
if (library) {
|
||||||
|
return (options.library ?? (() => emptyLibrary))(new URLSearchParams(library[1]))
|
||||||
|
}
|
||||||
if (url === '/v1/content-type') {
|
if (url === '/v1/content-type') {
|
||||||
return (options.content ?? (() => [...customTypes]))()
|
return (options.content ?? (() => [...customTypes]))()
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-4
@@ -68,6 +68,9 @@ const extraSources: Record<string, Source[]> = {
|
|||||||
'Key already taken',
|
'Key already taken',
|
||||||
'Schema incompatible',
|
'Schema incompatible',
|
||||||
'Offline',
|
'Offline',
|
||||||
|
'Media field selected',
|
||||||
|
'New media field, seven types',
|
||||||
|
'Media field, required',
|
||||||
],
|
],
|
||||||
alias: {},
|
alias: {},
|
||||||
},
|
},
|
||||||
@@ -81,6 +84,50 @@ const extraSources: Record<string, Source[]> = {
|
|||||||
'Delete type: blocked',
|
'Delete type: blocked',
|
||||||
'Delete type: confirm',
|
'Delete type: confirm',
|
||||||
'Delete type: server fallback (409)',
|
'Delete type: server fallback (409)',
|
||||||
|
'Review: required media field refused',
|
||||||
|
'Review: optional media field',
|
||||||
|
],
|
||||||
|
alias: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
board: schemaBoard,
|
||||||
|
group: '5 Content and entry list',
|
||||||
|
states: ['Thumbnail column', 'Columns menu with Thumbnail', 'Missing image: tooltip'],
|
||||||
|
alias: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
entry: [
|
||||||
|
{
|
||||||
|
board: schemaBoard,
|
||||||
|
group: '6 Entry editor',
|
||||||
|
states: [
|
||||||
|
'Media control: empty, optional',
|
||||||
|
'Media control: empty, required',
|
||||||
|
'Media control: filled',
|
||||||
|
'Media control: hover',
|
||||||
|
'Media control: focus',
|
||||||
|
'Media control: validation error',
|
||||||
|
'Media control: image no longer in the library',
|
||||||
|
'Media control: missing image, required',
|
||||||
|
'Media control: saving',
|
||||||
|
'Media control: read-only, type changed',
|
||||||
|
],
|
||||||
|
alias: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
board: schemaBoard,
|
||||||
|
group: '10 Media picker for fields',
|
||||||
|
states: [
|
||||||
|
'Library: nothing chosen',
|
||||||
|
'Library: image chosen',
|
||||||
|
'Library: No image chosen',
|
||||||
|
'Library: required field',
|
||||||
|
'Library: empty',
|
||||||
|
'Library: failed to load',
|
||||||
|
'Library: loading',
|
||||||
|
'Upload tab',
|
||||||
],
|
],
|
||||||
alias: {},
|
alias: {},
|
||||||
},
|
},
|
||||||
@@ -694,13 +741,22 @@ async function readScreen(name: string): Promise<Screen> {
|
|||||||
: headingStates.length
|
: headingStates.length
|
||||||
? headingStates
|
? headingStates
|
||||||
: tableStates(statesMarkdown)
|
: tableStates(statesMarkdown)
|
||||||
|
const configured = extraSources[name] ?? []
|
||||||
|
const sameGroup = configured.filter((source) => source.group === referenceGroups[0]!)
|
||||||
|
const mergedStates = [
|
||||||
|
...states,
|
||||||
|
...sameGroup.flatMap((source) => source.states).filter((state) => !states.includes(state)),
|
||||||
|
]
|
||||||
const primary: Source = {
|
const primary: Source = {
|
||||||
board: reference[1]!,
|
board: reference[1]!,
|
||||||
group: referenceGroups[0]!,
|
group: referenceGroups[0]!,
|
||||||
states,
|
states: mergedStates,
|
||||||
alias: boardAlias[name] ?? {},
|
alias: Object.assign(
|
||||||
|
{},
|
||||||
|
boardAlias[name] ?? {},
|
||||||
|
...sameGroup.map((source) => source.alias),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
const configured = extraSources[name] ?? []
|
|
||||||
const documentedExtras = referenceGroups
|
const documentedExtras = referenceGroups
|
||||||
.slice(1)
|
.slice(1)
|
||||||
.filter((group) => !configured.some((source) => source.group === group))
|
.filter((group) => !configured.some((source) => source.group === group))
|
||||||
@@ -709,7 +765,7 @@ async function readScreen(name: string): Promise<Screen> {
|
|||||||
name,
|
name,
|
||||||
board: primary.board,
|
board: primary.board,
|
||||||
group: primary.group,
|
group: primary.group,
|
||||||
states,
|
states: mergedStates,
|
||||||
sources: [
|
sources: [
|
||||||
primary,
|
primary,
|
||||||
...configured.filter((source) => source.group !== primary.group),
|
...configured.filter((source) => source.group !== primary.group),
|
||||||
|
|||||||
+585
-3
@@ -7425,11 +7425,593 @@ const entryStates: Record<string, VisualState> = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const thumbnailSize = 245760
|
||||||
|
const thumbnailDesktopSize = { width: 160, height: 120 }
|
||||||
|
const thumbnailMobileSize = { width: 298, height: 167 }
|
||||||
|
|
||||||
|
function thumbnailSvgFor({ width, height }: { width: number; height: number }) {
|
||||||
|
return [
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid slice">`,
|
||||||
|
'<style>.a{fill:#f3f2f0}.b{fill:#e6e4e0}.t{fill:#444}@media (prefers-color-scheme:dark){.a{fill:#1a1a1a}.b{fill:#222}.t{fill:#cfccc7}}</style>',
|
||||||
|
'<defs><pattern id="s" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">',
|
||||||
|
'<rect class="a" width="6" height="7"/><rect class="b" x="6" width="1" height="7"/></pattern></defs>',
|
||||||
|
`<rect fill="url(#s)" width="${width}" height="${height}"/>`,
|
||||||
|
`<text class="t" x="${width / 2}" y="${height / 2 + 4}" text-anchor="middle" font-family="IBM Plex Mono, monospace" font-size="11">1600 × 900</text>`,
|
||||||
|
'</svg>',
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Thumbnail {
|
||||||
|
id: number
|
||||||
|
filename: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailLime: Thumbnail = { id: 7, filename: 'lime-plaster-workshop.jpg' }
|
||||||
|
|
||||||
|
function thumbnailSummary({ id, filename }: Thumbnail) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
filename,
|
||||||
|
width: 1600,
|
||||||
|
height: 900,
|
||||||
|
size: thumbnailSize,
|
||||||
|
thumbnail_url: `/media/${filename}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailMissing = { id: 7, missing: true }
|
||||||
|
|
||||||
|
function thumbnailType(rest: object = {}) {
|
||||||
|
return {
|
||||||
|
...schemaCustomTypes[0]!,
|
||||||
|
fields: [
|
||||||
|
...schemaEventsFields,
|
||||||
|
schemaField('Thumbnail', 'thumbnail', 'media', { options: [], ...rest }),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbnailTypes(rest: object = {}) {
|
||||||
|
return [thumbnailType(rest), ...schemaCustomTypes.slice(1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbnailSchemaApi(
|
||||||
|
rest: object = {},
|
||||||
|
entries = 37,
|
||||||
|
overrides: Record<string, ApiMock> = {},
|
||||||
|
): Record<string, ApiMock> {
|
||||||
|
return schemaApi({
|
||||||
|
'GET /v1/content-type': { status: 200, body: thumbnailTypes(rest) },
|
||||||
|
'GET /v1/content-type/events/entry': { status: 200, body: schemaEntryPage(entries) },
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function thumbnailStubImages(page: Page, before?: () => Promise<void>) {
|
||||||
|
const size = schemaIsMobile(page) ? thumbnailMobileSize : thumbnailDesktopSize
|
||||||
|
await page.route(/\/media\/[^/?]+\.(jpg|png|svg|webp)(\?.*)?$/, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/svg+xml',
|
||||||
|
body: thumbnailSvgFor(size),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await before?.()
|
||||||
|
await page.reload()
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.querySelector('#app')?.textContent ?? '').trim().length > 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function thumbnailPickMedia(page: Page) {
|
||||||
|
await page.locator('label:has([role="radio"][value="media"])').click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function thumbnailAddMedia(page: Page, required = false) {
|
||||||
|
await schemaAddField(page, 'Thumbnail')
|
||||||
|
await thumbnailPickMedia(page)
|
||||||
|
if (required) {
|
||||||
|
await page.getByRole('switch', { name: 'Required' }).click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function thumbnailReview(page: Page, required: boolean) {
|
||||||
|
await thumbnailAddMedia(page, required)
|
||||||
|
await schemaCloseSheet(page)
|
||||||
|
await schemaSettle(page)
|
||||||
|
await page.getByRole('button', { name: 'Save', exact: true }).click()
|
||||||
|
await page.getByRole('alertdialog').waitFor()
|
||||||
|
await schemaSettle(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaSchemaStates: Record<string, VisualState> = {
|
||||||
|
'Media field selected': {
|
||||||
|
path: '/schema/events',
|
||||||
|
api: thumbnailSchemaApi(),
|
||||||
|
steps: async (page) => {
|
||||||
|
await schemaOpenRow(page, 'Thumbnail')
|
||||||
|
await schemaSettle(page)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'New media field, seven types': {
|
||||||
|
path: '/schema/events',
|
||||||
|
api: schemaApi(),
|
||||||
|
steps: async (page) => {
|
||||||
|
await thumbnailAddMedia(page)
|
||||||
|
await schemaSettle(page)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Media field, required': {
|
||||||
|
path: '/schema/events',
|
||||||
|
api: thumbnailSchemaApi({ required: true }),
|
||||||
|
steps: async (page) => {
|
||||||
|
await schemaOpenRow(page, 'Thumbnail')
|
||||||
|
await schemaSettle(page)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Review: required media field refused': {
|
||||||
|
path: '/schema/events',
|
||||||
|
api: schemaApi({
|
||||||
|
'GET /v1/content-type/events/entry': { status: 200, body: schemaEntryPage(12) },
|
||||||
|
}),
|
||||||
|
steps: async (page) => {
|
||||||
|
await thumbnailReview(page, true)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Review: optional media field': {
|
||||||
|
path: '/schema/events',
|
||||||
|
api: schemaApi({
|
||||||
|
'GET /v1/content-type/events/entry': { status: 200, body: schemaEntryPage(12) },
|
||||||
|
}),
|
||||||
|
steps: async (page) => {
|
||||||
|
await thumbnailReview(page, false)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailListSamples: Array<[number, Thumbnail | 'missing' | null]> = [
|
||||||
|
[42, thumbnailLime],
|
||||||
|
[41, { id: 8, filename: 'winter-open-day.jpg' }],
|
||||||
|
[40, null],
|
||||||
|
[39, 'missing'],
|
||||||
|
[38, { id: 10, filename: 'limewash-masterclass.jpg' }],
|
||||||
|
[37, null],
|
||||||
|
[36, { id: 11, filename: 'floorboards-talk.jpg' }],
|
||||||
|
]
|
||||||
|
|
||||||
|
const thumbnailRows = contentRows.map((row) => {
|
||||||
|
const [, sample] = thumbnailListSamples.find(([id]) => id === row.id)!
|
||||||
|
const stored = sample === null ? null : sample === 'missing' ? thumbnailMissing.id : sample.id
|
||||||
|
const media =
|
||||||
|
sample === null
|
||||||
|
? undefined
|
||||||
|
: sample === 'missing'
|
||||||
|
? { thumbnail: thumbnailMissing }
|
||||||
|
: { thumbnail: thumbnailSummary(sample) }
|
||||||
|
return { ...row, data: { ...row.data, thumbnail: stored }, ...(media ? { media } : {}) }
|
||||||
|
})
|
||||||
|
|
||||||
|
function thumbnailListApi(): Record<string, ApiMock> {
|
||||||
|
return contentApi({
|
||||||
|
'GET /v1/content-type': { status: 200, body: thumbnailTypes() },
|
||||||
|
'GET /v1/content-type/events': { status: 200, body: thumbnailType() },
|
||||||
|
'GET /v1/content-type/events/entry': {
|
||||||
|
status: 200,
|
||||||
|
bodyFor: () => ({ items: thumbnailRows, total: 7, page: 1, size: 25 }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailColumnsKey = 'renewcms-content-columns-events'
|
||||||
|
const thumbnailColumns = ['title', 'thumbnail', 'starts_on', 'category']
|
||||||
|
|
||||||
|
async function thumbnailList(page: Page) {
|
||||||
|
await thumbnailStubImages(page, () =>
|
||||||
|
page.evaluate(
|
||||||
|
([key, value]) => localStorage.setItem(key!, value!),
|
||||||
|
[thumbnailColumnsKey, JSON.stringify(thumbnailColumns)],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await page.locator('tbody tr, [data-testid="entry-card"]').first().waitFor()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaContentStates: Record<string, VisualState> = {
|
||||||
|
'Thumbnail column': {
|
||||||
|
path: '/content/events',
|
||||||
|
api: thumbnailListApi(),
|
||||||
|
steps: async (page) => {
|
||||||
|
await thumbnailList(page)
|
||||||
|
await schemaSettle(page)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Columns menu with Thumbnail': {
|
||||||
|
path: '/content/events',
|
||||||
|
api: thumbnailListApi(),
|
||||||
|
steps: async (page) => {
|
||||||
|
await thumbnailList(page)
|
||||||
|
await page.getByRole('button', { name: 'Columns' }).click()
|
||||||
|
await page.getByText('Created and updated dates always show').waitFor()
|
||||||
|
await schemaSettle(page)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Missing image: tooltip': {
|
||||||
|
path: '/content/events',
|
||||||
|
api: thumbnailListApi(),
|
||||||
|
steps: async (page) => {
|
||||||
|
await thumbnailList(page)
|
||||||
|
if (!schemaIsMobile(page)) {
|
||||||
|
await page.locator('[aria-label="Image no longer in the library"]').first().hover()
|
||||||
|
await page
|
||||||
|
.locator('[data-slot="content"]', { hasText: 'Image no longer in the library' })
|
||||||
|
.waitFor()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbnailEntryApi(
|
||||||
|
thumbnail: number | null,
|
||||||
|
media: object | null,
|
||||||
|
rest: object = {},
|
||||||
|
overrides: Record<string, ApiMock> = {},
|
||||||
|
): Record<string, ApiMock> {
|
||||||
|
return entryApi({
|
||||||
|
'GET /v1/content-type': { status: 200, body: thumbnailTypes(rest) },
|
||||||
|
'GET /v1/content-type/events': { status: 200, body: thumbnailType(rest) },
|
||||||
|
'GET /v1/content-type/events/entry/42': {
|
||||||
|
status: 200,
|
||||||
|
body: entryRecord({ thumbnail }, media ? { media: { thumbnail: media } } : {}),
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailFilled = () => thumbnailEntryApi(7, thumbnailSummary(thumbnailLime))
|
||||||
|
|
||||||
|
async function thumbnailAlign(page: Page, labelCenter: number) {
|
||||||
|
if (!schemaIsMobile(page)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await page.evaluate((wanted) => {
|
||||||
|
const label = document.querySelector('[data-entry-field="thumbnail"] label')!
|
||||||
|
const box = label.getBoundingClientRect()
|
||||||
|
let scroller: HTMLElement | null = label.parentElement
|
||||||
|
while (scroller && !/(auto|scroll)/.test(getComputedStyle(scroller).overflowY)) {
|
||||||
|
scroller = scroller.parentElement
|
||||||
|
}
|
||||||
|
scroller!.scrollTop += box.top + box.height / 2 - wanted
|
||||||
|
}, labelCenter)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function thumbnailEntry(page: Page, labelCenter: number, then?: () => Promise<void>) {
|
||||||
|
await thumbnailStubImages(page)
|
||||||
|
await page.getByTestId('media-control').scrollIntoViewIfNeeded()
|
||||||
|
await then?.()
|
||||||
|
await thumbnailAlign(page, labelCenter)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function thumbnailSubmitAndSettle(page: Page, expected: string) {
|
||||||
|
await thumbnailSubmit(page)
|
||||||
|
await page.getByText(expected).first().waitFor()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailMobileLabelCenter = 411
|
||||||
|
const thumbnailMobileLabelCenterWithAlert = 405
|
||||||
|
|
||||||
|
function thumbnailControlState(
|
||||||
|
api: Record<string, ApiMock>,
|
||||||
|
steps?: (page: Page) => Promise<void>,
|
||||||
|
labelCenter = thumbnailMobileLabelCenter,
|
||||||
|
): VisualState {
|
||||||
|
return {
|
||||||
|
path: '/content/events/42',
|
||||||
|
api,
|
||||||
|
steps: async (page) => {
|
||||||
|
await thumbnailEntry(page, labelCenter, async () => {
|
||||||
|
await steps?.(page)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function thumbnailSubmit(page: Page) {
|
||||||
|
await entryAppend(page, 'title', '.')
|
||||||
|
await entrySave(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaEntryStates: Record<string, VisualState> = {
|
||||||
|
'Media control: empty, optional': thumbnailControlState(thumbnailEntryApi(null, null)),
|
||||||
|
'Media control: empty, required': thumbnailControlState(
|
||||||
|
thumbnailEntryApi(null, null, { required: true }),
|
||||||
|
),
|
||||||
|
'Media control: filled': thumbnailControlState(thumbnailFilled()),
|
||||||
|
'Media control: hover': thumbnailControlState(thumbnailFilled(), async (page) => {
|
||||||
|
if (!schemaIsMobile(page)) {
|
||||||
|
await page.getByTestId('media-thumbnail').hover()
|
||||||
|
await page.getByTestId('media-hover-bar').waitFor()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
'Media control: focus': thumbnailControlState(thumbnailFilled(), async (page) => {
|
||||||
|
await page.getByRole('button', { name: 'Change image', exact: true }).focus()
|
||||||
|
await page.keyboard.press('Shift+Tab')
|
||||||
|
}),
|
||||||
|
'Media control: validation error': thumbnailControlState(
|
||||||
|
thumbnailEntryApi(null, null, { required: true }),
|
||||||
|
(page) => thumbnailSubmitAndSettle(page, 'Pick an image for Thumbnail.'),
|
||||||
|
),
|
||||||
|
'Media control: image no longer in the library': thumbnailControlState(
|
||||||
|
thumbnailEntryApi(7, thumbnailMissing),
|
||||||
|
),
|
||||||
|
'Media control: missing image, required': thumbnailControlState(
|
||||||
|
thumbnailEntryApi(7, thumbnailMissing, { required: true }),
|
||||||
|
(page) => thumbnailSubmitAndSettle(page, 'Pick an image for Thumbnail.'),
|
||||||
|
),
|
||||||
|
'Media control: saving': thumbnailControlState(
|
||||||
|
{
|
||||||
|
...thumbnailFilled(),
|
||||||
|
'PUT /v1/content-type/events/entry/42': { status: 200, hang: true },
|
||||||
|
},
|
||||||
|
async (page) => {
|
||||||
|
await entryAppend(page, 'title', ', two days')
|
||||||
|
await entrySave(page)
|
||||||
|
await page.getByText('Saving…').first().waitFor()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'Media control: read-only, type changed': thumbnailControlState(
|
||||||
|
{
|
||||||
|
...thumbnailEntryApi(7, thumbnailSummary(thumbnailLime), {}, {}),
|
||||||
|
'GET /v1/content-type/events/entry/42': {
|
||||||
|
status: 200,
|
||||||
|
body: entryRecord(
|
||||||
|
{ thumbnail: 7, capacity: 1 },
|
||||||
|
{ media: { thumbnail: thumbnailSummary(thumbnailLime) } },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
'PUT /v1/content-type/events/entry/42': {
|
||||||
|
status: 422,
|
||||||
|
body: 'invalid_entry: thumbnail: unknown_field',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (page) => {
|
||||||
|
await entryCapacityEdit(page)
|
||||||
|
await entrySave(page)
|
||||||
|
await page.getByText('The fields of Events changed').waitFor()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
},
|
||||||
|
thumbnailMobileLabelCenterWithAlert,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickerLibraryTotal = 48
|
||||||
|
|
||||||
|
function thumbnailLibrary(items = mediaItems(), total = pickerLibraryTotal): ApiMock {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page: 1,
|
||||||
|
size: 25,
|
||||||
|
counts: { all: total, unused: 2, duplicates: 2 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbnailPickerApi(
|
||||||
|
rest: object = {},
|
||||||
|
overrides: Record<string, ApiMock> = {},
|
||||||
|
): Record<string, ApiMock> {
|
||||||
|
const current = mediaItems([1])[0]!
|
||||||
|
return thumbnailEntryApi(
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
id: current.id,
|
||||||
|
filename: current.filename,
|
||||||
|
width: current.width,
|
||||||
|
height: current.height,
|
||||||
|
size: current.size,
|
||||||
|
thumbnail_url: current.url,
|
||||||
|
},
|
||||||
|
rest,
|
||||||
|
{ 'GET /v1/media/': thumbnailLibrary(), ...overrides },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function thumbnailOpenPicker(page: Page) {
|
||||||
|
await page.getByRole('button', { name: 'Change image', exact: true }).click()
|
||||||
|
await page.getByRole('dialog').waitFor()
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbnailPickerState(
|
||||||
|
api: Record<string, ApiMock>,
|
||||||
|
steps?: (page: Page) => Promise<void>,
|
||||||
|
): VisualState {
|
||||||
|
return {
|
||||||
|
path: '/content/events/42',
|
||||||
|
api,
|
||||||
|
steps: async (page) => {
|
||||||
|
await thumbnailOpenPicker(page)
|
||||||
|
await steps?.(page)
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaPickerStates: Record<string, VisualState> = {
|
||||||
|
'Library: nothing chosen': thumbnailPickerState(thumbnailPickerApi(), async (page) => {
|
||||||
|
await waitForPickerTiles(page)
|
||||||
|
}),
|
||||||
|
'Library: image chosen': thumbnailPickerState(thumbnailPickerApi(), async (page) => {
|
||||||
|
await selectPickerTile(page, 'logo-renew.svg')
|
||||||
|
}),
|
||||||
|
'Library: No image chosen': thumbnailPickerState(thumbnailPickerApi(), async (page) => {
|
||||||
|
await waitForPickerTiles(page)
|
||||||
|
await page.getByTestId('picker-no-image').click()
|
||||||
|
}),
|
||||||
|
'Library: required field': thumbnailPickerState(
|
||||||
|
thumbnailPickerApi({ required: true }),
|
||||||
|
async (page) => {
|
||||||
|
await waitForPickerTiles(page)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'Library: empty': thumbnailPickerState(
|
||||||
|
thumbnailPickerApi({}, { 'GET /v1/media/': thumbnailLibrary([], 0) }),
|
||||||
|
async (page) => {
|
||||||
|
await page.getByRole('tab', { name: 'Library', exact: true }).click()
|
||||||
|
await page.getByText('Your library is empty').waitFor()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'Library: failed to load': thumbnailPickerState(
|
||||||
|
thumbnailPickerApi({}, { 'GET /v1/media/': { status: 500 } }),
|
||||||
|
async (page) => {
|
||||||
|
await page.getByText("Couldn't load your library").waitFor()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'Library: loading': thumbnailPickerState(
|
||||||
|
thumbnailPickerApi({}, { 'GET /v1/media/': { status: 200, hang: true } }),
|
||||||
|
async (page) => {
|
||||||
|
await page.getByTestId('picker-skeleton-tile').first().waitFor()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'Upload tab': thumbnailPickerState(thumbnailPickerApi(), async (page) => {
|
||||||
|
await page.getByRole('tab', { name: 'Upload', exact: true }).click()
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaExceptions: Record<string, { desktop: number; mobile: number; reason: string }> = {
|
||||||
|
'schema|New media field, seven types': {
|
||||||
|
desktop: 13.0,
|
||||||
|
mobile: 19.5,
|
||||||
|
reason: 'the board draws a stray mobile-sized sheet overlay and a board miniature inside the desktop frame, and the mobile field sheet has the Move up, Move down and Remove row that the board does not draw',
|
||||||
|
},
|
||||||
|
'schema|Review: required media field refused': {
|
||||||
|
desktop: 45.0,
|
||||||
|
mobile: 10,
|
||||||
|
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states; the dark capture of the same state passes',
|
||||||
|
},
|
||||||
|
'schema|Review: optional media field': {
|
||||||
|
desktop: 33.0,
|
||||||
|
mobile: 10,
|
||||||
|
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states; the dark capture of the same state passes',
|
||||||
|
},
|
||||||
|
'schema|Media field selected': {
|
||||||
|
desktop: 10,
|
||||||
|
mobile: 24.0,
|
||||||
|
reason: 'the accepted spec requires a Move up, Move down and Remove row in the mobile field sheet that the board does not draw',
|
||||||
|
},
|
||||||
|
'schema|Media field, required': {
|
||||||
|
desktop: 10,
|
||||||
|
mobile: 24.0,
|
||||||
|
reason: 'the accepted spec requires a Move up, Move down and Remove row in the mobile field sheet that the board does not draw',
|
||||||
|
},
|
||||||
|
'entry|Media control: empty, optional': {
|
||||||
|
desktop: 38.5,
|
||||||
|
mobile: 10,
|
||||||
|
reason: 'the desktop board frames draw a stray miniature of the delete dialog state and an overlay of another viewport',
|
||||||
|
},
|
||||||
|
'entry|Media control: saving': {
|
||||||
|
desktop: 19.0,
|
||||||
|
mobile: 11.5,
|
||||||
|
reason: 'the light desktop board frame draws a stray miniature and overlay; on mobile the board pins the title row and chip above a form shifted by a negative margin, which a real page cannot do',
|
||||||
|
},
|
||||||
|
'entry|Media control: read-only, type changed': {
|
||||||
|
desktop: 18.5,
|
||||||
|
mobile: 25.5,
|
||||||
|
reason: 'the light desktop board frame draws a stray miniature and overlay; on mobile the board pins the title row and the warning alert above a form shifted by a negative margin, which a real page cannot do',
|
||||||
|
},
|
||||||
|
'entry|Library: nothing chosen': {
|
||||||
|
desktop: 17.5,
|
||||||
|
mobile: 11.5,
|
||||||
|
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
|
||||||
|
},
|
||||||
|
'entry|Library: image chosen': {
|
||||||
|
desktop: 42.0,
|
||||||
|
mobile: 13.0,
|
||||||
|
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
|
||||||
|
},
|
||||||
|
'entry|Library: No image chosen': {
|
||||||
|
desktop: 42.5,
|
||||||
|
mobile: 13.0,
|
||||||
|
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
|
||||||
|
},
|
||||||
|
'entry|Library: required field': {
|
||||||
|
desktop: 42.5,
|
||||||
|
mobile: 12.5,
|
||||||
|
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
|
||||||
|
},
|
||||||
|
'entry|Library: empty': {
|
||||||
|
desktop: 41.5,
|
||||||
|
mobile: 11.5,
|
||||||
|
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
|
||||||
|
},
|
||||||
|
'entry|Library: failed to load': {
|
||||||
|
desktop: 43.0,
|
||||||
|
mobile: 15.0,
|
||||||
|
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
|
||||||
|
},
|
||||||
|
'entry|Library: loading': {
|
||||||
|
desktop: 40.5,
|
||||||
|
mobile: 10,
|
||||||
|
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
|
||||||
|
},
|
||||||
|
'entry|Upload tab': {
|
||||||
|
desktop: 31.0,
|
||||||
|
mobile: 10,
|
||||||
|
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
|
||||||
|
},
|
||||||
|
'entry|Media control: filled': {
|
||||||
|
desktop: 10,
|
||||||
|
mobile: 11.5,
|
||||||
|
reason: 'the light mobile board frame draws a stray top-right miniature of the board, about 7% of the frame',
|
||||||
|
},
|
||||||
|
'entry|Media control: hover': {
|
||||||
|
desktop: 10,
|
||||||
|
mobile: 11.5,
|
||||||
|
reason: 'the light mobile board frame draws a stray top-right miniature of the board, about 7% of the frame',
|
||||||
|
},
|
||||||
|
'entry|Media control: focus': {
|
||||||
|
desktop: 10,
|
||||||
|
mobile: 12.0,
|
||||||
|
reason: 'the light mobile board frame draws a stray top-right miniature of the board, about 7% of the frame',
|
||||||
|
},
|
||||||
|
'entry|Media control: validation error': {
|
||||||
|
desktop: 10,
|
||||||
|
mobile: 10.5,
|
||||||
|
reason: 'on mobile the board pins the title row and chip above a form shifted by a negative margin, which a real page cannot do',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function withMediaExceptions(screen: string, states: Record<string, VisualState>) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(states).map(([name, state]) => {
|
||||||
|
const cap = mediaExceptions[`${screen}|${name}`]
|
||||||
|
return [
|
||||||
|
name,
|
||||||
|
cap
|
||||||
|
? {
|
||||||
|
...state,
|
||||||
|
exception: {
|
||||||
|
maxPercent: { desktop: cap.desktop, mobile: cap.mobile },
|
||||||
|
reason: cap.reason,
|
||||||
|
approvedBy: 'fsylvain',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: state,
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||||
sites: sitesStates,
|
sites: sitesStates,
|
||||||
schema: schemaStates,
|
schema: { ...schemaStates, ...withMediaExceptions('schema', mediaSchemaStates) },
|
||||||
content: contentStates,
|
content: { ...contentStates, ...mediaContentStates },
|
||||||
entry: entryStates,
|
entry: {
|
||||||
|
...entryStates,
|
||||||
|
...withMediaExceptions('entry', { ...mediaEntryStates, ...mediaPickerStates }),
|
||||||
|
},
|
||||||
authors: authorsStates,
|
authors: authorsStates,
|
||||||
activity: activityStates,
|
activity: activityStates,
|
||||||
profile: {
|
profile: {
|
||||||
|
|||||||
Reference in new issue
Block a user