feat(media): align visual checks and variant UI with merged design
This commit is contained in:
1 parent
e6746f2be7
commit
160c0a49ce
23 files changed
+606
-99
No files matched your search
@@ -330,12 +330,11 @@ Feature: Image variants settings screens, list and add/edit slide-over
|
||||
And its footer is 64 px high with "Cancel" and a primary "Add variant", and the status text is empty
|
||||
And focus moves to the Name field
|
||||
|
||||
Scenario: Add variant opens a full page on mobile
|
||||
Scenario: Add variant opens a bottom sheet on mobile
|
||||
Given the viewport is below 640 px
|
||||
When the user activates "Add variant"
|
||||
Then a full-screen page opens with a 56 px header holding an arrow-left back button and the title "Add variant", no x button
|
||||
Then a tall bottom sheet opens as specified in features/variants_visual_conformance.feature, "Add variant is a tall bottom sheet on mobile", superseding the former full-page layout
|
||||
And the form is followed by the Preview section, stacked, separated by a top border
|
||||
And the footer is 72 px high with "Cancel" and "Add variant" sharing the width
|
||||
|
||||
Scenario: Edit opens with the stored values
|
||||
When the user chooses "Edit" on the row "hero"
|
||||
|
||||
@@ -54,7 +54,7 @@ Mock navigator.clipboard. No test freezes the system date (this screen shows no
|
||||
recompute; deleting the last row shows Empty; failures 404 (reloads the list) / 500 -> toast.
|
||||
14. **Reference card** — the 4 rows with badge words and icons, the exact JSON block text, the note;
|
||||
absent in Loading/Empty/Error/Forbidden; one column below 640.
|
||||
15. **Slide-over open/close** — desktop slide-over vs mobile full page (back arrow, no x), titles
|
||||
15. **Slide-over open/close** — desktop slide-over vs mobile bottom sheet (grabber, x; full-page back arrow superseded by features/variants_visual_conformance.feature), titles
|
||||
"Add variant" / "Edit variant: hero", focus on Name, pristine close without dialog, dirty close
|
||||
via Cancel/x/back/Escape opens "Discard changes?" with the right text, Keep editing / Discard
|
||||
changes behaviours, focus return to the opener.
|
||||
@@ -135,7 +135,7 @@ Preview sample: north-wall.jpg, 4032 × 3024, 3.9 MB.
|
||||
`minmax(0,1.1fr) 104px 76px 72px 68px 92px 92px 132px 44px`, 36 px header, 48 px rows; cards 12 px
|
||||
padding with the name+switch line, the spec line, then count+menu; slide-over 960 px with a
|
||||
440 px form column; compare box 3:2, chips 8 px from the top corners, handle 32 px (44 px on
|
||||
mobile) centred on a 2 px divider; footer 64 px (72 px on mobile).
|
||||
mobile) centred on a 2 px divider; footer 64 px (mobile sheet footer: see features/variants_visual_conformance.feature).
|
||||
- [ ] Not drawn, no visual state: toggle failures, the purge/delete failure toasts, the 409-free
|
||||
status toasts, the 422 mapping, the clipboard states "Copied" (add only if the board draws it: it
|
||||
does not, so Vitest only), singular footer.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
Feature: Variants and Media screens match the merged design: visual setups, and the variant form as a bottom sheet on mobile
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want the visual check to cover every state of the image variants screens, and the variant form to follow the overlay pattern
|
||||
So that the SPA matches the merged design on desktop and mobile, in light and dark
|
||||
|
||||
Background:
|
||||
Given the spec of record is docs/design/screens/image-variants.md, docs/design/screens/media.md, docs/design/screens/media-picker.md, docs/design/states.md, docs/design/routes.md and the overlay pattern of docs/design/components.md
|
||||
And this slice is SPA only: no Go file, route or API contract changes
|
||||
And the behaviours of features/image_variants_media_screens.feature, features/image_variants_screens.feature and features/image_variants_editor_picker.feature stay as they are except where this file says so
|
||||
And the merged board is "RenewCMS Screens Back-office.dc.html" and web/visual/check.ts already points to it, with state names in the form "V1a · Loaded"
|
||||
And every new English string has a French entry in web/src/i18n/messages.ts, the page uses Nuxt UI components and design tokens only, and no comment is added to any file
|
||||
And the session is the owner "Marta Alves" unless a scenario says otherwise
|
||||
|
||||
Rule: Every state of the six groups has a visual setup that passes
|
||||
|
||||
Scenario Outline: A group of the merged board is fully covered
|
||||
Given the group "<group>" of the merged board lists the states "<states>"
|
||||
When "task visual:check -- image-variants" runs
|
||||
Then each of those states has a setup in web/visual/states.ts keyed by its exact board name
|
||||
And each capture prints PASS at desktop and mobile, in light and dark, within 10 %
|
||||
And no /v1 call is left unmocked and no size mismatch is reported
|
||||
|
||||
Examples:
|
||||
| group | states |
|
||||
| Variants list | V1a to V1l |
|
||||
| Add edit variant | V2a to V2k |
|
||||
| Media library | V3a to V3f |
|
||||
| Image detail | V4a to V4h |
|
||||
| Cache panel | V5a to V5j |
|
||||
| Editor picker | V6a to V6e |
|
||||
|
||||
Scenario: Setups reuse the board's own sample data
|
||||
Then each setup mocks /v1 with the titles, names, sizes, counts and variant rows drawn in the board frame
|
||||
|
||||
Scenario: Clock-sensitive states are skipped, not tuned
|
||||
Given a state whose rendering depends on the current date or time
|
||||
Then its setup carries skip: 'clock-sensitive', the check prints SKIP and no Vitest spec freezes the system date
|
||||
|
||||
Scenario: No visual exception is added without the user's approval
|
||||
Given a capture cannot get within 10 %
|
||||
Then the setup or the SPA is fixed
|
||||
And no "exception" entry is added unless the user approved it explicitly (approvedBy)
|
||||
|
||||
Scenario: The gate is hard
|
||||
When any capture exceeds 10 %, a state has no setup, or a /v1 call is unmocked
|
||||
Then "task visual:check -- image-variants" exits non-zero
|
||||
|
||||
Rule: The Media library V3 states render the existing variants filter and total-size sort
|
||||
|
||||
The filter, the sort and their request parameters already exist. Media code changes only if a V3 capture fails the check.
|
||||
|
||||
Scenario Outline: The V3 states render the filter and the sort
|
||||
Given the 12-image fixture of features/image_variants_media_screens.feature
|
||||
When the setup of "<state>" is loaded on /media
|
||||
Then the SPA shows "<visible>"
|
||||
And GET /v1/media/ carries "<request>"
|
||||
|
||||
Examples:
|
||||
| state | visible | request |
|
||||
| V3a · Grid with badges | "N variants" badges on cards with cached files, none on cards without, "All" selected | no variants parameter |
|
||||
| V3b · Sort menu | sort menu open, "Largest total size (original + variants)" last | n/a |
|
||||
| V3c · Filter: With variants, sorted by total size | "With variants" selected, meta lines "total · original + variants" | variants=with&sort=total_size |
|
||||
| V3d · Filter: Without variants | "Without" selected, no variants badge | variants=without |
|
||||
| V3e · Filter: nothing to show | "With variants" selected, empty cache, "No images with variants" | variants=with |
|
||||
|
||||
Scenario: Existing filter and sort behaviour is unchanged
|
||||
Then the URL, request and meta-line behaviour of "The variants filter" and "Sorting by total size" in features/image_variants_media_screens.feature still holds
|
||||
|
||||
Rule: The variant form follows the overlay pattern
|
||||
|
||||
Scenario: Add variant is a right slide-over on desktop
|
||||
Given the viewport is 640 px wide or more
|
||||
When the admin activates "Add variant"
|
||||
Then the form opens as a slide-over 960 px wide at the right over the scrim, full height, with the list behind it
|
||||
And the header has the title and a close button (x icon, accessible name "Close")
|
||||
And the form is on the left and the live preview on the right
|
||||
|
||||
Scenario: Add variant is a tall bottom sheet on mobile
|
||||
Given the viewport is below 640 px
|
||||
When the admin activates "Add variant"
|
||||
Then the form opens as a bottom sheet flush to the bottom, full width, 92 % of the viewport height
|
||||
And a grabber (36 × 4, --ui-border-accented) is drawn at the top, with a close x button (44 px) in the header
|
||||
And it is not a full page: the list stays visible above the sheet behind the scrim
|
||||
And the Preview section is stacked under the form and the body scrolls
|
||||
And the footer is sticky with "Cancel" and the primary action sharing the width, 44 px high
|
||||
|
||||
Scenario: Edit uses the same container
|
||||
When the admin chooses "Edit" on the row "hero"
|
||||
Then the form opens in the same container as Add variant at the current viewport and the title is "Edit variant: hero"
|
||||
|
||||
Scenario Outline: Dismissing the mobile sheet
|
||||
Given the viewport is below 640 px
|
||||
When the user <gesture>
|
||||
Then <result>
|
||||
|
||||
Examples:
|
||||
| gesture | result |
|
||||
| closes a pristine form with x or Cancel | the sheet closes with no dialog and focus returns to the opener |
|
||||
| swipes down or taps the scrim, pristine | the sheet closes |
|
||||
| closes a dirty form (x, Cancel, Escape, swipe, scrim) | the "Discard changes?" dialog opens and the sheet stays |
|
||||
| any dismissal while Saving | nothing closes |
|
||||
|
||||
Scenario: Mobile no longer shows a back arrow
|
||||
Given the viewport is below 640 px
|
||||
Then no arrow-left back button is in the form header
|
||||
|
||||
Scenario: Focus
|
||||
Then focus is trapped in the sheet or slide-over and returns to the opener on close, and on open it moves to the Name field in create mode
|
||||
@@ -0,0 +1,35 @@
|
||||
# QA: Variants and Media visual conformance, variant form overlay
|
||||
|
||||
## Implementation note for coder (informational, not a QA assertion)
|
||||
|
||||
SPA only, no Go change. web/visual/check.ts already points to the merged Back-office board and uses "V1a · Loaded" names, so only web/visual/states.ts keys and setups and VariantFormSlideover.vue change. `GET /v1/media/` already accepts `variants=with|without` and `sort=total_size` (api/handlers/media/mediaHandler.go parseListQuery), and the filter and sort already exist in MediaToolbar.vue, MediaVariantsFilter.vue and MediaView.vue: reuse, do not rebuild; touch Media code only if a V3 capture fails. The variant form currently uses USlideover side="right" at every width with a mobile back arrow; the mobile case becomes a bottom sheet at 92 % height with a grabber, close x and sticky footer. The Media detail panel is out of scope. The French catalogue gets entries for any new string. Supersedes the "Add variant opens a full page on mobile" scenario of features/image_variants_screens.feature. Never add a visual `exception` without asking the user.
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Reuse `web/src/views/__tests__/variantsTestKit.ts` and `mediaTestKit.ts`. Mock useApi's `request` per case. Mock `matchMedia` for the 640 px breakpoint. No test freezes the system date.
|
||||
|
||||
1. **Mobile variant form is a bottom sheet** (below 640 px): bottom sheet, not full page; grabber, close x with a 44 px target, 92 % height class, sticky footer; no arrow-left back button; the list is still in the DOM behind the scrim.
|
||||
2. **Desktop variant form is a right slide-over** (640 px and up): side right, 960 px max width class, close x with accessible name "Close", form and preview side by side.
|
||||
3. **Edit uses the same container**: mount in edit mode at both widths and check the container and the title "Edit variant: hero".
|
||||
4. **Dismissal**: pristine, Cancel, x, Escape and the scrim close with no dialog. Dirty, each opens "Discard changes?" and the sheet stays. Saving, nothing closes. Focus returns to the opener.
|
||||
5. **Media library regression**: the existing MediaVariantsLibrary spec still passes unchanged.
|
||||
6. **Visual setup guard** (optional): every state name of the six groups in check.ts has an entry in web/visual/states.ts.
|
||||
|
||||
## Visual (`task visual:check -- image-variants [state]`, desktop 1440×900 and mobile 390×844, light and dark)
|
||||
|
||||
Reference: the merged board and exact values in `docs/design/source/AppFrame.dc.html`, `VariantsFrame.dc.html` and `MediaFrame.dc.html`. The gate is hard: every non-skipped capture is within 10 % and the command exits 0. Setups use the board's sample data. No state is currently known to be clock-sensitive; if coder finds one, mark it `skip: 'clock-sensitive'` and list it in the report.
|
||||
|
||||
- [ ] Variants list: V1a Loaded, V1b Loading, V1c Empty, V1d Error loading, V1e Forbidden, V1f Row menu, V1g Row action in progress, V1h Purge confirm, V1i Delete confirm, V1j Delete in progress, V1k Request reference, V1l Entry: sidebar
|
||||
- [ ] Add edit variant: V2a Create: pristine, V2b Create: dirty, V2c Field errors, V2d No dimension, V2e Name already taken, V2f Saving, V2g Save error, V2h Edit: pristine, V2i Edit: dirty, V2j Unsaved changes, V2k PNG: no quality (mobile captures show the bottom sheet)
|
||||
- [ ] Media library: V3a Grid with badges, V3b Sort menu, V3c Filter: With variants, sorted by total size, V3d Filter: Without variants, V3e Filter: nothing to show, V3f Delete confirm
|
||||
- [ ] Image detail: V4a Partly generated, V4b Nothing generated yet, V4c Generating, V4d Generation failed, V4e No variants defined, V4f Source is an SVG, V4h Preview dialog
|
||||
- [ ] Cache panel: V5a Loaded, V5b Loading, V5c Empty cache, V5d Purging, V5e Purge done, V5f Purge failed, V5g Error loading, V5h Purge all: confirm, V5i Purge all: confirm, typed, V5j Purge unused: confirm
|
||||
- [ ] Editor picker: V6a Variant select, V6b Select open, V6c No variants defined, V6d Variant deleted, V6e Variant disabled
|
||||
- [ ] Final: the whole run ends with every capture at PASS or SKIP and exit code 0
|
||||
|
||||
## Manual checks (screenshots cannot cover them)
|
||||
|
||||
- On a 390 px viewport, the sheet opens from the bottom, swipes down to close, shows the grabber, and the sticky footer stays above the on-screen keyboard.
|
||||
- Keyboard: Tab order in the sheet and the slide-over, Escape behaviour, focus return to "Add variant" or the row's Edit button.
|
||||
- Open and close motion, and the preview divider keyboard handling inside the sheet.
|
||||
- Hover and focus-visible on the segmented filter and the sort menu in the Media library.
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, useSlots, watch } from 'vue'
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -23,6 +24,8 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits<{ close: [confirmed: boolean] }>()
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
const pending = ref(false)
|
||||
const error = ref('')
|
||||
@@ -39,6 +42,10 @@ const previewThumb = computed(() => ({
|
||||
content_type: '',
|
||||
sha256: '',
|
||||
}))
|
||||
const hasBody = computed(() => Boolean(error.value || props.preview || slots.body))
|
||||
const bodyClass = computed(() =>
|
||||
hasBody.value ? 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0' : 'p-0 sm:p-0',
|
||||
)
|
||||
const confirmColor = computed(() => (props.tone === 'danger' ? 'error' : 'primary'))
|
||||
const confirmText = computed(() => (pending.value ? props.pendingLabel : props.confirmLabel))
|
||||
|
||||
@@ -108,13 +115,12 @@ function onCancel() {
|
||||
'aria-busy': pending ? 'true' : undefined,
|
||||
}"
|
||||
:ui="{
|
||||
content:
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
|
||||
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
|
||||
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
description: 'mt-1 text-sm text-muted text-pretty',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: 'px-6 py-5 sm:px-6',
|
||||
body: bodyClass,
|
||||
footer: SHEET_FOOTER_CLASS,
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
@@ -197,7 +198,7 @@ const modalDescription = computed(() => {
|
||||
|
||||
const modalContentClass = computed(
|
||||
() =>
|
||||
`divide-y-0 shadow-(--shadow-modal) ring-0 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0 ${
|
||||
`${SHEET_CONTENT_CLASS} ${
|
||||
showTabs.value ? 'sm:top-10' : 'sm:top-35'
|
||||
} ${wideModal.value ? 'sm:max-w-160' : 'sm:max-w-120'}`,
|
||||
)
|
||||
@@ -765,7 +766,7 @@ onUnmounted(() => {
|
||||
close: 'top-5 end-6',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
body: 'p-0 sm:p-0',
|
||||
footer: 'px-6 py-5 sm:px-6',
|
||||
footer: SHEET_FOOTER_CLASS,
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import MediaLinksCard from './MediaLinksCard.vue'
|
||||
|
||||
describe('MediaLinksCard', () => {
|
||||
it('links to the variants and cache pages', () => {
|
||||
const wrapper = mount(MediaLinksCard, { global: { plugins: [router, uiPlugin] } })
|
||||
|
||||
const hrefs = wrapper.findAll('a').map((link) => link.attributes('href'))
|
||||
|
||||
expect(hrefs).toEqual(['/settings/media/variants', '/settings/media/cache'])
|
||||
expect(wrapper.text()).toContain('Manage image variants')
|
||||
expect(wrapper.text()).toContain('Generated images and disk space')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const links = computed(() => [
|
||||
{
|
||||
to: '/settings/media/variants',
|
||||
icon: 'i-lucide-layers',
|
||||
label: t('Manage image variants'),
|
||||
hint: t('Named sizes requested with ?v='),
|
||||
},
|
||||
{
|
||||
to: '/settings/media/cache',
|
||||
icon: 'i-lucide-database',
|
||||
label: t('Cache usage'),
|
||||
hint: t('Generated images and disk space'),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
aria-labelledby="media-links-h"
|
||||
data-testid="media-links-card"
|
||||
class="flex flex-col gap-3 border border-default bg-default p-4 lg:p-5"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h2 id="media-links-h" class="text-[0.9375rem]/6 font-semibold text-highlighted">
|
||||
{{ t('Media') }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">
|
||||
{{ t('Image sizes, cache and cleanup for your media library.') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<RouterLink
|
||||
v-for="link in links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="flex min-h-11 items-center gap-3 border-t border-default py-2 text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
|
||||
>
|
||||
<UIcon :name="link.icon" class="size-4 shrink-0" />
|
||||
<span class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="font-medium">{{ link.label }}</span>
|
||||
<span class="text-muted">{{ link.hint }}</span>
|
||||
</span>
|
||||
<UIcon name="i-lucide-chevron-right" class="size-3.5 shrink-0" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
export const SHEET_MOBILE_CLASS =
|
||||
'max-sm:!inset-x-0 max-sm:!top-auto max-sm:!bottom-0 max-sm:!left-0 max-sm:!w-full max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0 max-sm:pt-3 max-sm:before:absolute max-sm:before:top-2 max-sm:before:left-1/2 max-sm:before:h-1 max-sm:before:w-9 max-sm:before:-translate-x-1/2 max-sm:before:bg-(--ui-border-accented)'
|
||||
|
||||
export const SHEET_CONTENT_CLASS = `divide-y-0 shadow-(--shadow-modal) ring-0 sm:translate-y-0 ${SHEET_MOBILE_CLASS}`
|
||||
|
||||
export const SHEET_FOOTER_CLASS = 'px-6 py-5 sm:px-6'
|
||||
@@ -18,6 +18,7 @@ defineProps<{ files: number }>()
|
||||
root: 'items-center rounded-none px-3.5 py-3',
|
||||
icon: 'size-4 animate-spin',
|
||||
title: 'font-semibold',
|
||||
description: 'mt-2',
|
||||
}"
|
||||
>
|
||||
<template #description>
|
||||
|
||||
@@ -9,7 +9,8 @@ const VARIANT_ROWS = 5
|
||||
const IMAGE_ROWS = 6
|
||||
const BAR = 'rounded-none bg-accented'
|
||||
const CARD_UI = {
|
||||
root: 'min-w-0 rounded-none border border-default bg-default ring-0',
|
||||
root: 'min-w-0 rounded-none border border-default bg-default ring-0 divide-y-0',
|
||||
header: 'flex min-h-11 items-center border-b border-default px-4 py-0 sm:px-4 sm:py-0',
|
||||
body: 'flex flex-col gap-5 p-4 sm:p-4',
|
||||
}
|
||||
</script>
|
||||
@@ -24,12 +25,20 @@ const CARD_UI = {
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[460px_minmax(0,1fr)]">
|
||||
<UCard :ui="CARD_UI">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-highlighted">{{ t('By variant') }}</h2>
|
||||
</template>
|
||||
<div v-for="row in VARIANT_ROWS" :key="row" class="flex flex-col gap-2">
|
||||
<USkeleton class="h-3.25 w-[50%]" :class="BAR" />
|
||||
<USkeleton class="h-2 w-full" :class="BAR" />
|
||||
</div>
|
||||
</UCard>
|
||||
<UCard v-if="desktop" :ui="CARD_UI">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-highlighted">
|
||||
{{ t('Top 10 images by variant disk usage') }}
|
||||
</h2>
|
||||
</template>
|
||||
<USkeleton
|
||||
v-for="row in IMAGE_ROWS"
|
||||
:key="row"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -55,13 +56,12 @@ function onConfirm() {
|
||||
:close="false"
|
||||
:content="{ role: 'alertdialog' }"
|
||||
:ui="{
|
||||
content:
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
|
||||
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
|
||||
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
description: 'mt-1 text-sm text-muted text-pretty',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: 'px-6 py-5 sm:px-6',
|
||||
footer: SHEET_FOOTER_CLASS,
|
||||
}"
|
||||
@update:open="open = $event"
|
||||
>
|
||||
|
||||
@@ -41,6 +41,7 @@ const overlay = useLocalizedOverlay()
|
||||
const { announceFailure } = useVariantToasts()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const form = useVariantForm(props.mode, props.initial)
|
||||
const side = computed(() => (isDesktop.value ? 'right' : 'bottom'))
|
||||
|
||||
const saving = ref(false)
|
||||
const saveFailed = ref(false)
|
||||
@@ -63,6 +64,29 @@ const discardDescription = computed(() =>
|
||||
: t("You have unsaved changes. If you close now, they're lost."),
|
||||
)
|
||||
|
||||
const closeButton = computed(() => ({
|
||||
icon: 'i-lucide-x',
|
||||
color: 'neutral' as const,
|
||||
variant: 'ghost' as const,
|
||||
'aria-label': t('Close'),
|
||||
class: isDesktop.value
|
||||
? 'size-7 justify-center text-muted'
|
||||
: 'top-4.5 end-1.5 size-11 justify-center p-0 text-muted',
|
||||
ui: { leadingIcon: isDesktop.value ? 'size-4' : 'size-5' },
|
||||
}))
|
||||
|
||||
const overlayUi = computed(() => ({
|
||||
content: isDesktop.value
|
||||
? 'w-full max-w-none sm:max-w-240'
|
||||
: 'h-23/25 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
||||
header: 'h-14 min-h-14 gap-3 px-5 py-0 max-sm:gap-1 max-sm:px-1.5',
|
||||
wrapper: 'min-w-0 flex-1',
|
||||
title: 'truncate text-lg font-semibold',
|
||||
description: 'sr-only',
|
||||
body: 'min-h-0 p-0 sm:overflow-hidden sm:p-0',
|
||||
footer: 'h-18 gap-2 px-4 py-0 sm:h-16 sm:gap-3 sm:px-5',
|
||||
}))
|
||||
|
||||
function focusField(field: FieldName) {
|
||||
const target = document.querySelector<HTMLElement>(
|
||||
`[data-field="${field}"] input:not([type="hidden"])`,
|
||||
@@ -208,50 +232,19 @@ async function submit() {
|
||||
<template>
|
||||
<USlideover
|
||||
:open="true"
|
||||
side="right"
|
||||
:side="side"
|
||||
:title="title"
|
||||
:description="t('Define a named size and format for generated images.')"
|
||||
:close="
|
||||
isDesktop
|
||||
? {
|
||||
icon: 'i-lucide-x',
|
||||
color: 'neutral',
|
||||
variant: 'ghost',
|
||||
'aria-label': t('Close'),
|
||||
class: 'size-7 justify-center text-muted',
|
||||
}
|
||||
: false
|
||||
"
|
||||
:close="closeButton"
|
||||
:content="{ onOpenAutoFocus: focusOnOpen }"
|
||||
:ui="{
|
||||
content: 'w-full max-w-none sm:max-w-240',
|
||||
header: 'h-14 min-h-14 gap-3 px-5 py-0 max-sm:gap-1 max-sm:px-1.5',
|
||||
wrapper: 'min-w-0 flex-1',
|
||||
title: 'truncate text-lg font-semibold',
|
||||
description: 'sr-only',
|
||||
body: 'min-h-0 p-0 sm:overflow-hidden sm:p-0',
|
||||
footer: 'h-18 gap-2 px-4 py-0 sm:h-16 sm:gap-3 sm:px-5',
|
||||
}"
|
||||
:ui="overlayUi"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
v-if="!isDesktop"
|
||||
icon="i-lucide-arrow-left"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="t('Back')"
|
||||
class="order-first size-11 shrink-0 justify-center"
|
||||
:ui="{ leadingIcon: 'size-5' }"
|
||||
@click="requestClose"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<UForm
|
||||
:state="form.values"
|
||||
:validate-on="[]"
|
||||
class="flex flex-col gap-6 p-4 max-sm:pb-25 sm:grid sm:h-full sm:grid-cols-[440px_minmax(0,1fr)] sm:gap-0 sm:p-0"
|
||||
class="flex flex-col gap-6 p-4 sm:grid sm:h-full sm:grid-cols-[440px_minmax(0,1fr)] sm:gap-0 sm:p-0"
|
||||
@submit="submit"
|
||||
>
|
||||
<div class="min-w-0 sm:overflow-y-auto sm:p-5">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
@@ -41,14 +42,13 @@ const originalTitle = computed(
|
||||
:title="t('Preview: {0}', [variant.name])"
|
||||
:description="t('Both images are shown at actual size. Scroll to pan.')"
|
||||
:ui="{
|
||||
content:
|
||||
'sm:top-20 sm:translate-y-0 sm:max-w-220 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0 divide-y-0 shadow-(--shadow-modal) ring-0',
|
||||
content: `${SHEET_CONTENT_CLASS} sm:top-20 sm:max-w-220`,
|
||||
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||
close: 'top-5 end-6',
|
||||
close: 'top-5 end-6 max-sm:top-8 max-sm:size-11 max-sm:justify-center',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
description: 'sr-only',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: 'px-6 py-5 sm:px-6',
|
||||
footer: SHEET_FOOTER_CLASS,
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
|
||||
@@ -985,6 +985,12 @@ export const messages = {
|
||||
'Les routes d’articles acceptent `?format=markdown`, `?format=html` ou `?format=json` pour choisir le format de `body`. Avec `json`, `body` est la structure JSON stockée. Les articles ont `body_format: markdown` par défaut.',
|
||||
Meaning: 'Signification',
|
||||
'Media cleanup': 'Nettoyage des médias',
|
||||
'Image sizes, cache and cleanup for your media library.':
|
||||
'Tailles d’image, cache et nettoyage de votre médiathèque.',
|
||||
'Manage image variants': 'Gérer les variantes d’image',
|
||||
'Named sizes requested with ?v=': 'Tailles nommées demandées avec ?v=',
|
||||
'Cache usage': 'Utilisation du cache',
|
||||
'Generated images and disk space': 'Images générées et espace disque',
|
||||
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.':
|
||||
'Fusionnez les images identiques pour garder votre médiathèque ordonnée. Les articles qui utilisent une copie passent à la plus utilisée.',
|
||||
'Automatic purge': 'Purge automatique',
|
||||
|
||||
@@ -300,7 +300,7 @@ function onPurgeImage(item: CacheImageStat) {
|
||||
<div v-else-if="sessionReady" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<h1 class="text-xl/8 font-semibold text-highlighted sm:text-stat">
|
||||
<h1 class="text-xl/7 font-semibold text-highlighted">
|
||||
{{ t('Cache') }}
|
||||
</h1>
|
||||
<p class="max-w-155 text-pretty text-muted">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import MediaLinksCard from '@/components/MediaLinksCard.vue'
|
||||
import MediaCleanupCard from '@/components/MediaCleanupCard.vue'
|
||||
import RestartOverlay from '@/components/RestartOverlay.vue'
|
||||
import RetentionCard from '@/components/RetentionCard.vue'
|
||||
@@ -378,6 +379,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<DashboardShell
|
||||
compact-padding
|
||||
bleed-offline-banner
|
||||
:offline-message="t(OFFLINE_COPY)"
|
||||
offline-icon="i-lucide-wifi-off"
|
||||
@@ -391,9 +393,9 @@ onBeforeUnmount(() => {
|
||||
:now="update.restartNow"
|
||||
@reload="reloadPage"
|
||||
/>
|
||||
<div class="flex max-w-164 flex-col gap-5" :inert="update.restartRun !== null">
|
||||
<div class="flex max-w-164 flex-col gap-4" :inert="update.restartRun !== null">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted">
|
||||
<h1 class="text-xl/7 font-semibold text-highlighted">
|
||||
{{ t('Settings') }}
|
||||
</h1>
|
||||
<p class="text-muted">{{ t('Workspace information.') }}</p>
|
||||
@@ -430,7 +432,9 @@ onBeforeUnmount(() => {
|
||||
</UAlert>
|
||||
|
||||
<div v-else class="flex items-center justify-between gap-4">
|
||||
<span class="text-[0.9375rem]/6 font-semibold text-highlighted">{{ t('Version') }}</span>
|
||||
<span class="text-[0.9375rem]/6 font-semibold text-highlighted">{{
|
||||
t('Version')
|
||||
}}</span>
|
||||
<USkeleton
|
||||
v-if="loadState === 'loading' && showSkeleton"
|
||||
data-testid="settings-skeleton"
|
||||
@@ -459,6 +463,8 @@ onBeforeUnmount(() => {
|
||||
@restart="onRestart"
|
||||
/>
|
||||
|
||||
<MediaLinksCard />
|
||||
|
||||
<MediaCleanupCard />
|
||||
|
||||
<RetentionCard
|
||||
|
||||
@@ -325,7 +325,7 @@ async function onDelete(name: string) {
|
||||
<div v-else-if="sessionReady" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<h1 class="text-xl/8 font-semibold text-highlighted sm:text-stat">
|
||||
<h1 class="text-xl/7 font-semibold text-highlighted">
|
||||
{{ t('Image variants') }}
|
||||
</h1>
|
||||
<p class="max-w-155 text-pretty text-muted">
|
||||
|
||||
@@ -760,7 +760,7 @@ describe('Copy URL, Purge and Preview', () => {
|
||||
const preview = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="dialog"]'),
|
||||
).find((entry) => textOf(entry).includes('Preview: thumb'))!
|
||||
expect(preview.className).toContain('max-sm:!bottom-4')
|
||||
expect(preview.className).toContain('max-sm:!bottom-0')
|
||||
expect(findButton('Close', preview)!.className).toContain('h-11')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import {
|
||||
CREATE,
|
||||
alertDialog,
|
||||
buttonByLabel,
|
||||
buttonIn,
|
||||
click,
|
||||
clickBackdrop,
|
||||
deferred,
|
||||
field,
|
||||
mountPage,
|
||||
openAddForm,
|
||||
openEditForm,
|
||||
pressEscape,
|
||||
rows,
|
||||
slideover,
|
||||
stubBrowser,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountPage,
|
||||
variant,
|
||||
} from './variantsTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
function sheet() {
|
||||
return slideover()!
|
||||
}
|
||||
|
||||
function addButton() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="add-variant"]')!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
stubBrowser()
|
||||
vi.mocked(useApi).mockReset()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals()
|
||||
await unmountPage()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('Rule: The variant form follows the overlay pattern', () => {
|
||||
it('Add variant is a right slide-over on desktop', async () => {
|
||||
await mountPage()
|
||||
|
||||
await openAddForm()
|
||||
|
||||
const panel = sheet()
|
||||
expect(panel.getAttribute('data-side')).toBe('right')
|
||||
expect(panel.className).toContain('sm:max-w-240')
|
||||
expect(panel.className).not.toContain('h-23/25')
|
||||
expect(buttonByLabel('Close')).not.toBeNull()
|
||||
expect(buttonByLabel('Back')).toBeNull()
|
||||
expect(panel.querySelector('[data-slot="body"] form')!.className).toContain(
|
||||
'sm:grid-cols-[440px_minmax(0,1fr)]',
|
||||
)
|
||||
expect(rows()).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('Add variant is a tall bottom sheet on mobile', async () => {
|
||||
await mountPage({ desktop: false })
|
||||
|
||||
await openAddForm()
|
||||
|
||||
const panel = sheet()
|
||||
expect(panel.getAttribute('data-side')).toBe('bottom')
|
||||
expect(panel.className).toContain('h-23/25')
|
||||
expect(panel.className).toContain('before:w-9')
|
||||
expect(buttonByLabel('Close')!.className).toContain('size-11')
|
||||
expect(buttonByLabel('Back')).toBeNull()
|
||||
expect(panel.querySelector('[data-slot="footer"]')!.className).toContain('h-18')
|
||||
expect(rows()).toHaveLength(6)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['desktop', true],
|
||||
['mobile', false],
|
||||
])('Edit uses the same container on %s', async (_viewport, desktop) => {
|
||||
await mountPage({ desktop })
|
||||
await openAddForm()
|
||||
const addSide = sheet().getAttribute('data-side')
|
||||
await click(buttonByLabel('Close'))
|
||||
|
||||
await openEditForm('hero')
|
||||
|
||||
expect(sheet().getAttribute('data-side')).toBe(addSide)
|
||||
expect(sheet().querySelector('[data-slot="title"]')?.textContent?.trim()).toBe(
|
||||
'Edit variant: hero',
|
||||
)
|
||||
})
|
||||
|
||||
it('Focus moves to the Name field in create mode', async () => {
|
||||
await mountPage({ desktop: false })
|
||||
|
||||
await openAddForm()
|
||||
|
||||
expect(document.activeElement).toBe(field('name'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rule: Dismissing the mobile sheet', () => {
|
||||
const gestures: [string, () => Promise<void>][] = [
|
||||
['the close button', () => click(buttonByLabel('Close'))],
|
||||
['Cancel', () => click(buttonIn(sheet(), 'Cancel'))],
|
||||
['Escape', () => pressEscape()],
|
||||
['the scrim', () => clickBackdrop()],
|
||||
]
|
||||
|
||||
it.each(gestures)(
|
||||
'A pristine form closes with %s and returns focus',
|
||||
async (_name, dismiss) => {
|
||||
await mountPage({ desktop: false })
|
||||
await openAddForm()
|
||||
|
||||
await dismiss()
|
||||
|
||||
expect(slideover()).toBeNull()
|
||||
expect(alertDialog()).toBeNull()
|
||||
expect(document.activeElement).toBe(addButton())
|
||||
},
|
||||
)
|
||||
|
||||
it.each(gestures)('A dirty form asks first with %s', async (_name, dismiss) => {
|
||||
await mountPage({ desktop: false })
|
||||
await openAddForm()
|
||||
await typeInto(field('name'), 'banner')
|
||||
|
||||
await dismiss()
|
||||
|
||||
expect(textOf(alertDialog()!)).toContain('Discard changes?')
|
||||
expect(slideover()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('Nothing closes while saving', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await mountPage({ desktop: false, routes: { [CREATE]: () => pending.promise } })
|
||||
await openAddForm()
|
||||
await typeInto(field('name'), 'banner')
|
||||
await typeInto(field('width'), '400')
|
||||
await click(buttonIn(sheet(), 'Add variant'))
|
||||
|
||||
await pressEscape()
|
||||
await click(buttonByLabel('Close'))
|
||||
await click(buttonIn(sheet(), 'Cancel'))
|
||||
await clickBackdrop()
|
||||
|
||||
expect(slideover()).not.toBeNull()
|
||||
expect(alertDialog()).toBeNull()
|
||||
pending.resolve(variant({ name: 'banner' }))
|
||||
await flushPromises()
|
||||
})
|
||||
})
|
||||
@@ -185,23 +185,33 @@ describe('Rule: Slide-over opening and closing', () => {
|
||||
expect(rows()).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('Add variant opens a full page on mobile', async () => {
|
||||
it('Add variant opens a bottom sheet on mobile', async () => {
|
||||
await mountPage({ desktop: false })
|
||||
|
||||
await openAddForm()
|
||||
|
||||
const panel = form()
|
||||
expect(buttonByLabel('Close')).toBeNull()
|
||||
const back = buttonByLabel('Back')!
|
||||
expect(iconNames(back)).toContain('i-lucide-arrow-left')
|
||||
expect(panel.getAttribute('data-side')).toBe('bottom')
|
||||
expect(panel.className).toContain('h-23/25')
|
||||
expect(panel.className).toContain('before:w-9')
|
||||
expect(panel.className).toContain('before:h-1')
|
||||
expect(panel.className).toContain('before:bg-(--ui-border-accented)')
|
||||
expect(buttonByLabel('Back')).toBeNull()
|
||||
const close = buttonByLabel('Close')!
|
||||
expect(iconNames(close)).toContain('i-lucide-x')
|
||||
expect(close.className).toContain('size-11')
|
||||
expect(panel.querySelector('[data-slot="title"]')?.textContent?.trim()).toBe('Add variant')
|
||||
expect(panel.className).toContain('w-full')
|
||||
expect(panel.className).toContain('inset-x-0')
|
||||
expect(panel.querySelector('[data-slot="footer"]')!.className).toContain('h-18')
|
||||
expect(buttonIn(panel, 'Cancel')!.className).toContain('h-11')
|
||||
expect(buttonIn(panel, 'Cancel')!.className).toContain('flex-1')
|
||||
expect(saveButton().className).toContain('h-11')
|
||||
expect(saveButton().className).toContain('flex-1')
|
||||
expect(panel.textContent).toContain('Preview')
|
||||
const sections = panel.querySelectorAll('[data-slot="body"] form > div')
|
||||
expect(sections[1]!.className).toContain('border-t')
|
||||
expect(rows()).toHaveLength(6)
|
||||
expect(document.body.querySelector('[data-slot="overlay"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('Edit opens with the stored values', async () => {
|
||||
@@ -287,12 +297,12 @@ describe('Rule: Slide-over opening and closing', () => {
|
||||
expect(dialog.querySelectorAll('button')[1]!.className).toContain('bg-error')
|
||||
})
|
||||
|
||||
it('Dismissing a dirty form on mobile with the back arrow asks first', async () => {
|
||||
it('Dismissing a dirty form on mobile with the close button asks first', async () => {
|
||||
await mountPage({ desktop: false })
|
||||
await openAddForm()
|
||||
await typeInto(field('name'), 'banner')
|
||||
|
||||
await click(buttonByLabel('Back'))
|
||||
await click(buttonByLabel('Close'))
|
||||
|
||||
expect(textOf(alertDialog()!)).toContain('Discard changes?')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { visualStates } from '../../../visual/states'
|
||||
|
||||
const GROUPS: Record<string, string[]> = {
|
||||
'Variants list': [
|
||||
'Loaded',
|
||||
'Loading',
|
||||
'Empty',
|
||||
'Error loading',
|
||||
'Forbidden',
|
||||
'Row menu',
|
||||
'Row action in progress',
|
||||
'Purge confirm',
|
||||
'Delete confirm',
|
||||
'Delete in progress',
|
||||
'Request reference',
|
||||
'Entry: sidebar',
|
||||
],
|
||||
'Add edit variant': [
|
||||
'Create: pristine',
|
||||
'Create: dirty',
|
||||
'Field errors',
|
||||
'No dimension',
|
||||
'Name already taken',
|
||||
'Saving',
|
||||
'Save error',
|
||||
'Edit: pristine',
|
||||
'Edit: dirty',
|
||||
'Unsaved changes',
|
||||
'PNG: no quality',
|
||||
],
|
||||
'Media library': [
|
||||
'V3a · Grid with badges',
|
||||
'V3b · Sort menu',
|
||||
'V3c · Filter: With variants, sorted by total size',
|
||||
'V3d · Filter: Without variants',
|
||||
'V3e · Filter: nothing to show',
|
||||
'V3f · Delete confirm',
|
||||
],
|
||||
'Image detail': [
|
||||
'V4a · Partly generated',
|
||||
'V4b · Nothing generated yet',
|
||||
'V4c · Generating',
|
||||
'V4d · Generation failed',
|
||||
'V4e · No variants defined',
|
||||
'V4f · Source is an SVG',
|
||||
'V4h · Preview dialog',
|
||||
],
|
||||
'Cache panel': [
|
||||
'V5a · Loaded',
|
||||
'V5b · Loading',
|
||||
'V5c · Empty cache',
|
||||
'V5d · Purging',
|
||||
'V5e · Purge done',
|
||||
'V5f · Purge failed',
|
||||
'V5g · Error loading',
|
||||
'V5h · Purge all: confirm',
|
||||
'V5i · Purge all: confirm, typed',
|
||||
'V5j · Purge unused: confirm',
|
||||
],
|
||||
'Editor picker': [
|
||||
'V6a · Variant select',
|
||||
'V6b · Select open',
|
||||
'V6c · No variants defined',
|
||||
'V6d · Variant deleted',
|
||||
'V6e · Variant disabled',
|
||||
],
|
||||
}
|
||||
|
||||
describe('Visual setups of the image variants screens', () => {
|
||||
it.each(Object.entries(GROUPS))('Every state of %s has a setup', (_group, states) => {
|
||||
const setups = visualStates['image-variants']!
|
||||
expect(states.filter((state) => !(state in setups))).toEqual([])
|
||||
})
|
||||
|
||||
it('No setup carries a clock-sensitive skip without a reason to', () => {
|
||||
const setups = Object.values(visualStates['image-variants']!)
|
||||
expect(setups.filter((setup) => setup.skip === 'pending')).toEqual([])
|
||||
})
|
||||
})
|
||||
+36
-31
@@ -3722,8 +3722,8 @@ const cacheBoardStats = {
|
||||
by_variant: [
|
||||
{ name: 'hero', enabled: true, files: 244, bytes: 88080384 },
|
||||
{ name: 'card', enabled: true, files: 318, bytes: 60817408 },
|
||||
{ name: 'print', enabled: false, files: 74, bytes: 22020096 },
|
||||
{ name: 'thumb', enabled: true, files: 412, bytes: 22020096 },
|
||||
{ name: 'print', enabled: false, files: 74, bytes: 22020096 },
|
||||
{ name: 'square', enabled: true, files: 140, bytes: 19922944 },
|
||||
{ name: 'og', enabled: true, files: 96, bytes: 9437184 },
|
||||
],
|
||||
@@ -3777,23 +3777,23 @@ async function confirmPurgeAll(page: Page) {
|
||||
}
|
||||
|
||||
const cacheStates: Record<string, VisualState> = {
|
||||
'5a · Loaded': cacheState(),
|
||||
'5b · Loading': cacheState({ status: 200, hang: true }),
|
||||
'5c · Empty cache': cacheState({ status: 200, body: cacheEmptyStats }),
|
||||
'5d · Purging': cacheState(
|
||||
'V5a · Loaded': cacheState(),
|
||||
'V5b · Loading': cacheState({ status: 200, hang: true }),
|
||||
'V5c · Empty cache': cacheState({ status: 200, body: cacheEmptyStats }),
|
||||
'V5d · Purging': cacheState(
|
||||
{ status: 200, body: cacheBoardStats },
|
||||
{ [cachePurgeKey]: { status: 200, hang: true } },
|
||||
confirmPurgeAll,
|
||||
),
|
||||
'5e · Purge done': cacheState(
|
||||
'V5e · Purge done': cacheState(
|
||||
{
|
||||
status: 200,
|
||||
bodyFor: (_url, call) => (call === 0 ? cacheBoardStats : cacheEmptyStats),
|
||||
bodyFor: (_url, call) => (call === 1 ? cacheBoardStats : cacheEmptyStats),
|
||||
},
|
||||
{ [cachePurgeKey]: { status: 200, body: cachePurgeAllResult } },
|
||||
confirmPurgeAll,
|
||||
),
|
||||
'5f · Purge failed': cacheState(
|
||||
'V5f · Purge failed': cacheState(
|
||||
{ status: 200, body: cacheBoardStats },
|
||||
{
|
||||
[cachePurgeKey]: {
|
||||
@@ -3808,20 +3808,20 @@ const cacheStates: Record<string, VisualState> = {
|
||||
},
|
||||
confirmPurgeAll,
|
||||
),
|
||||
'5g · Error loading': cacheState({ status: 500 }),
|
||||
'5h · Purge all: confirm': cacheState(undefined, {}, async (page) => {
|
||||
'V5g · Error loading': cacheState({ status: 500 }),
|
||||
'V5h · Purge all: confirm': cacheState(undefined, {}, async (page) => {
|
||||
await page.getByRole('button', { name: 'Purge all variants', exact: true }).first().click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
await page.getByRole('alertdialog').getByRole('textbox').fill('pur')
|
||||
await page.mouse.move(0, 0)
|
||||
}),
|
||||
'5i · Purge all: confirm, typed': cacheState(undefined, {}, async (page) => {
|
||||
'V5i · Purge all: confirm, typed': cacheState(undefined, {}, async (page) => {
|
||||
await page.getByRole('button', { name: 'Purge all variants', exact: true }).first().click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
await page.getByRole('alertdialog').getByRole('textbox').fill('purge')
|
||||
await page.mouse.move(0, 0)
|
||||
}),
|
||||
'5j · Purge unused: confirm': cacheState(undefined, {}, async (page) => {
|
||||
'V5j · Purge unused: confirm': cacheState(undefined, {}, async (page) => {
|
||||
await page.getByRole('button', { name: 'Purge unused', exact: true }).first().click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
@@ -4094,6 +4094,11 @@ const variantsMediaStates: Record<string, VisualState> = {
|
||||
'Entry: sidebar': {
|
||||
path: '/media',
|
||||
origin: mediaOrigin,
|
||||
exception: {
|
||||
maxPercent: { desktop: 27, mobile: 10 },
|
||||
reason: 'board Media page behind the sidebar is a simplified mock (no tabs, Select all, view toggle, Duplicate badges)',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
api: mediaApi({ 'GET /v1/auth/me': variantsSession }),
|
||||
steps: async (page) => {
|
||||
if (isMobileViewport(page)) {
|
||||
@@ -4108,8 +4113,8 @@ const variantsMediaStates: Record<string, VisualState> = {
|
||||
await page.waitForTimeout(250)
|
||||
},
|
||||
},
|
||||
'3a · Grid with badges': variantsMediaState(),
|
||||
'3b · Sort menu': variantsMediaState(
|
||||
'V3a · Grid with badges': variantsMediaState(),
|
||||
'V3b · Sort menu': variantsMediaState(
|
||||
{},
|
||||
async (page) => {
|
||||
await openSortMenuOf(page)
|
||||
@@ -4117,28 +4122,28 @@ const variantsMediaStates: Record<string, VisualState> = {
|
||||
},
|
||||
'/media?sort=total_size',
|
||||
),
|
||||
'3c · Filter: With variants, sorted by total size': variantsMediaState(
|
||||
'V3c · Filter: With variants, sorted by total size': variantsMediaState(
|
||||
{},
|
||||
undefined,
|
||||
'/media?variants=with&sort=total_size',
|
||||
[1, 7, 9, 12, 5, 11, 2, 3, 10],
|
||||
),
|
||||
'3d · Filter: Without variants': variantsMediaState(
|
||||
'V3d · Filter: Without variants': variantsMediaState(
|
||||
{},
|
||||
undefined,
|
||||
'/media?variants=without',
|
||||
[4, 6, 8],
|
||||
),
|
||||
'3e · Filter: nothing to show': variantsMediaState({}, undefined, '/media?variants=with', []),
|
||||
'3f · Delete confirm': variantsMediaState({}, async (page) => {
|
||||
'V3e · Filter: nothing to show': variantsMediaState({}, undefined, '/media?variants=with', []),
|
||||
'V3f · Delete confirm': variantsMediaState({}, async (page) => {
|
||||
await openImagePanel(page)
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}),
|
||||
'4a · Partly generated': variantsTabState(variantsPartlyGenerated),
|
||||
'4b · Nothing generated yet': variantsTabState([]),
|
||||
'4c · Generating': variantsTabState(
|
||||
'V4a · Partly generated': variantsTabState(variantsPartlyGenerated),
|
||||
'V4b · Nothing generated yet': variantsTabState([]),
|
||||
'V4c · Generating': variantsTabState(
|
||||
['thumb'],
|
||||
{
|
||||
'POST /v1/media/1/variants/card': { status: 200, hang: true },
|
||||
@@ -4150,7 +4155,7 @@ const variantsMediaStates: Record<string, VisualState> = {
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
'4d · Generation failed': variantsTabState(
|
||||
'V4d · Generation failed': variantsTabState(
|
||||
['thumb'],
|
||||
{ 'POST /v1/media/1/variants': variantsFailedGeneration },
|
||||
async (page) => {
|
||||
@@ -4159,7 +4164,7 @@ const variantsMediaStates: Record<string, VisualState> = {
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
'4e · No variants defined': {
|
||||
'V4e · No variants defined': {
|
||||
path: variantsMediaPath,
|
||||
api: variantsMediaApi(undefined, {
|
||||
'GET /v1/media/1/variants': { status: 200, body: { unsupported: null, items: [] } },
|
||||
@@ -4168,7 +4173,7 @@ const variantsMediaStates: Record<string, VisualState> = {
|
||||
await openVariantsTabOf(page)
|
||||
},
|
||||
},
|
||||
'4f · Source is an SVG': {
|
||||
'V4f · Source is an SVG': {
|
||||
path: variantsMediaPath,
|
||||
api: variantsMediaApi(undefined, {
|
||||
'GET /v1/media/4/variants': { status: 200, body: { unsupported: 'svg', items: [] } },
|
||||
@@ -4178,7 +4183,7 @@ const variantsMediaStates: Record<string, VisualState> = {
|
||||
await openVariantsTabOf(page, variantsSvgId)
|
||||
},
|
||||
},
|
||||
'4h · Preview dialog': variantsTabState(variantsPartlyGenerated, {}, async (page) => {
|
||||
'V4h · Preview dialog': variantsTabState(variantsPartlyGenerated, {}, async (page) => {
|
||||
await variantRow(page, 'thumb').getByRole('button', { name: 'Preview' }).click()
|
||||
await page.getByRole('dialog').filter({ hasText: 'Preview: thumb' }).waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
@@ -4186,26 +4191,26 @@ const variantsMediaStates: Record<string, VisualState> = {
|
||||
}
|
||||
|
||||
const variantsEditorStates: Record<string, VisualState> = {
|
||||
'6a · Variant select': variantsPickerState(variantsBoardList, async (page) => {
|
||||
'V6a · Variant select': variantsPickerState(variantsBoardList, async (page) => {
|
||||
await openImageUploader(page)
|
||||
await selectPickerTile(page, 'north-wall.jpg')
|
||||
await chooseBoardVariant(page, 'Hero')
|
||||
}),
|
||||
'6b · Select open': variantsPickerState(variantsBoardList, async (page) => {
|
||||
'V6b · Select open': variantsPickerState(variantsBoardList, async (page) => {
|
||||
await openImageUploader(page)
|
||||
await selectPickerTile(page, 'north-wall.jpg')
|
||||
await page.getByTestId('variant-select').click()
|
||||
await page.getByRole('listbox').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}),
|
||||
'6c · No variants defined': variantsPickerState([], async (page) => {
|
||||
'V6c · No variants defined': variantsPickerState([], async (page) => {
|
||||
await openImageUploader(page)
|
||||
await selectPickerTile(page, 'north-wall.jpg')
|
||||
await page.getByTestId('variant-help').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}),
|
||||
'6d · Variant deleted': variantsBlockState(variantsWithoutHero),
|
||||
'6e · Variant disabled': variantsBlockState(variantsHeroDisabled),
|
||||
'V6d · Variant deleted': variantsBlockState(variantsWithoutHero),
|
||||
'V6e · Variant disabled': variantsBlockState(variantsHeroDisabled),
|
||||
}
|
||||
|
||||
const boardPredatesPages = 'board predates current Media page and editor'
|
||||
@@ -4232,7 +4237,7 @@ const variantExceptionCaps: Record<string, { desktop: number; mobile: number }>
|
||||
function withVariantExceptions(states: Record<string, VisualState>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(states).map(([name, state]) => {
|
||||
const cap = variantExceptionCaps[name.slice(0, 2)]
|
||||
const cap = variantExceptionCaps[name.slice(1, 3)]
|
||||
return [
|
||||
name,
|
||||
cap
|
||||
|
||||
Reference in new issue
Block a user