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
@@ -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