feat: editor language menu, title field, header actions, publish dialogs, read-only rendering and draft recovery
This commit is contained in:
1 parent
a671b81f80
commit
339e646eb8
85 files changed
+6369
-624
No files matched your search
+14
-2
@@ -2,11 +2,18 @@
|
||||
import { computed } from 'vue'
|
||||
import { en, fr } from '@nuxt/ui/locale'
|
||||
import { locale } from '@/i18n'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { RouterView, type RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
|
||||
import { liveMessage } from '@/composables/useLiveAnnouncement'
|
||||
|
||||
const BULK_BAR_TOAST_OFFSET = 'max-sm:bottom-20'
|
||||
|
||||
const EDITOR_PATH = '/article/:id/edit'
|
||||
|
||||
function routeKey(route: RouteLocationNormalizedLoaded) {
|
||||
return route.matched.some((record) => record.path === EDITOR_PATH) ? route.params.id : undefined
|
||||
}
|
||||
|
||||
const toaster = computed(() => ({
|
||||
ui: { viewport: toastsAboveBulkBar.value ? BULK_BAR_TOAST_OFFSET : '' },
|
||||
}))
|
||||
@@ -14,6 +21,11 @@ const toaster = computed(() => ({
|
||||
|
||||
<template>
|
||||
<UApp class="isolate" :toaster="toaster" :locale="locale === 'fr' ? fr : en">
|
||||
<RouterView />
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<component :is="Component" :key="routeKey(route)" />
|
||||
</RouterView>
|
||||
<div aria-live="polite" class="sr-only" data-testid="live-announcement">
|
||||
{{ liveMessage }}
|
||||
</div>
|
||||
</UApp>
|
||||
</template>
|
||||
@@ -7,7 +7,7 @@ import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { bodyImageCount } from '@/lib/bodyImageIds'
|
||||
|
||||
const props = defineProps<{ article: Article }>()
|
||||
const props = defineProps<{ article: Article; multilingual?: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ 'copy-url': [] }>()
|
||||
|
||||
@@ -15,8 +15,13 @@ const toast = useLocalizedToast()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const TITLE_NOTE =
|
||||
"Titles can't be changed after an article is created. To use a different title, create a new article and delete this one."
|
||||
const titleNote = computed(() =>
|
||||
props.multilingual
|
||||
? t(
|
||||
"Each language has its own title. Changing it doesn't change the URL; edit the slug in Search and sharing.",
|
||||
)
|
||||
: t("Changing the title doesn't change the URL; edit the slug in Search and sharing."),
|
||||
)
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'Jan',
|
||||
@@ -141,7 +146,7 @@ async function copyUrl() {
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 border-t border-default pt-3 text-sm">
|
||||
<p class="text-muted">{{ t('Title') }}</p>
|
||||
<p class="text-xs/[18px] text-toned">{{ t(TITLE_NOTE) }}</p>
|
||||
<p class="text-xs/[18px] text-toned">{{ titleNote }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
|
||||
@@ -18,9 +18,13 @@ const props = withDefaults(
|
||||
modelValue: string
|
||||
articleId: number
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
label?: string
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
label: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -47,6 +51,15 @@ const extensions = [
|
||||
}),
|
||||
]
|
||||
|
||||
const editorProps = computed(() => ({
|
||||
attributes: {
|
||||
role: 'textbox',
|
||||
'aria-multiline': 'true',
|
||||
...(props.label ? { 'aria-label': props.label } : {}),
|
||||
...(props.readonly ? { 'aria-readonly': 'true' } : {}),
|
||||
},
|
||||
}))
|
||||
|
||||
function captureEditor(editor: Editor) {
|
||||
editorInstance.value = editor
|
||||
return ''
|
||||
@@ -359,14 +372,15 @@ defineExpose({
|
||||
:starter-kit="{ link: false }"
|
||||
:image="false"
|
||||
content-type="html"
|
||||
:editable="!props.disabled"
|
||||
:editable="!props.disabled && !props.readonly"
|
||||
:editor-props="editorProps"
|
||||
:ui="{ base: 'article-content' }"
|
||||
class="article-editor grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] grid-cols-[minmax(0,1fr)] lg:grid-cols-[minmax(0,1fr)_17.5rem]"
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<span class="hidden">{{ captureEditor(editor) }}</span>
|
||||
<UEditorDragHandle :editor="editor" />
|
||||
<div class="relative col-span-full row-start-1 min-w-0">
|
||||
<div v-if="!props.readonly" class="relative col-span-full row-start-1 min-w-0">
|
||||
<UEditorToolbar
|
||||
ref="toolbarRef"
|
||||
:editor="editor"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
|
||||
import LocaleDialog from '@/components/localization/LocaleDialog.vue'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel: string
|
||||
notice?: { title: string; description: string }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [confirmed: boolean] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LocaleDialog
|
||||
:title="title"
|
||||
:description="description"
|
||||
:confirm-label="confirmLabel"
|
||||
@close="emit('close', false)"
|
||||
@confirm="emit('close', true)"
|
||||
>
|
||||
<LocaleAlert
|
||||
v-if="notice"
|
||||
tone="neutral"
|
||||
icon="i-lucide-info"
|
||||
:title="notice.title"
|
||||
:description="notice.description"
|
||||
/>
|
||||
</LocaleDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import AppSwitch from '@/components/AppSwitch.vue'
|
||||
import EditorOverflowMenu from '@/components/EditorOverflowMenu.vue'
|
||||
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||
import { primaryLabel, unpublishLabel } from '@/lib/editorLabels'
|
||||
import type { ActionPlan } from '@/lib/editorActions'
|
||||
import type { OverflowItem } from '@/lib/editorOverflow'
|
||||
|
||||
defineProps<{
|
||||
plan: ActionPlan
|
||||
language: string | null
|
||||
offline: boolean
|
||||
unpublishVisible: boolean
|
||||
overflow: OverflowItem[]
|
||||
previewable: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ save: []; primary: []; unpublish: [] }>()
|
||||
|
||||
const previewOn = defineModel<boolean>('preview', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<AppSwitch
|
||||
v-if="previewable"
|
||||
v-model="previewOn"
|
||||
:label="t('Preview')"
|
||||
class="me-2"
|
||||
:ui="{
|
||||
root: 'items-center gap-2',
|
||||
wrapper: 'ms-0',
|
||||
label: 'text-sm font-normal text-toned',
|
||||
}"
|
||||
/>
|
||||
<LocaleButton
|
||||
v-if="plan.unpublish && unpublishVisible"
|
||||
:disabled="offline"
|
||||
@click="emit('unpublish')"
|
||||
>
|
||||
{{ unpublishLabel(language) }}
|
||||
</LocaleButton>
|
||||
<LocaleButton
|
||||
v-if="plan.saveButton"
|
||||
data-testid="save-button"
|
||||
icon="i-lucide-save"
|
||||
:disabled="plan.saveButton.disabled || offline"
|
||||
@click="emit('save')"
|
||||
>
|
||||
{{ t('Save') }}
|
||||
</LocaleButton>
|
||||
<LocaleButton
|
||||
v-if="plan.primary"
|
||||
data-testid="primary-action"
|
||||
kind="primary"
|
||||
:loading="plan.primary.busy"
|
||||
:class="plan.primary.busy ? '!opacity-40' : ''"
|
||||
:aria-busy="plan.primary.busy ? 'true' : undefined"
|
||||
:disabled="plan.primary.disabled || offline"
|
||||
@click="emit('primary')"
|
||||
>
|
||||
{{ primaryLabel(plan.primary.kind, language) }}
|
||||
</LocaleButton>
|
||||
<EditorOverflowMenu v-if="overflow.length" :items="overflow" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,111 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import AppSwitch from '@/components/AppSwitch.vue'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import PublishControl from '@/components/PublishControl.vue'
|
||||
import SaveStatus from '@/components/SaveStatus.vue'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import type { ArticleDraftState } from '@/composables/useArticleDraft'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
article: Article
|
||||
state: ArticleDraftState
|
||||
savedAt: Date | null
|
||||
countText: string
|
||||
saveEnabled: boolean
|
||||
publishPending: string | undefined
|
||||
publishDisabled: boolean
|
||||
canPublish?: boolean
|
||||
canDelete?: boolean
|
||||
deleteReason?: string
|
||||
}>(),
|
||||
{ canPublish: true, canDelete: true, deleteReason: '' },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
retry: []
|
||||
save: []
|
||||
publish: []
|
||||
unpublish: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const previewOn = defineModel<boolean>('preview', { required: true })
|
||||
defineEmits<{ back: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-default bg-default px-4 py-3 lg:gap-3 lg:px-6 lg:py-3.5"
|
||||
class="flex shrink-0 flex-col gap-2.5 border-b border-default bg-default px-4 py-3 lg:px-6 lg:py-3.5"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-arrow-left"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="hidden size-8 justify-center text-toned lg:inline-flex"
|
||||
:aria-label="t('Back to Articles')"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<div
|
||||
class="flex min-w-0 flex-1 basis-full flex-wrap items-center gap-2.5 lg:min-w-50 lg:basis-50"
|
||||
>
|
||||
<h1
|
||||
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
|
||||
:title="t('The title can\'t be changed after creation.')"
|
||||
>
|
||||
{{ article.title }}
|
||||
</h1>
|
||||
<ArticleStatusBadge :online="article.is_online" size="lg" />
|
||||
<span v-if="!canPublish && !article.is_online" class="text-xs text-muted">{{
|
||||
t('Ask an editor to publish.')
|
||||
}}</span>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="hidden h-15 items-end lg:flex">
|
||||
<UButton
|
||||
icon="i-lucide-arrow-left"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="size-4 justify-center p-0 text-toned"
|
||||
:aria-label="t('Back to Articles')"
|
||||
@click="$emit('back')"
|
||||
/>
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<slot name="title" />
|
||||
</div>
|
||||
</div>
|
||||
<SaveStatus :state="state" :saved-at="savedAt" @retry="emit('retry')" />
|
||||
<span class="ml-auto font-mono text-xs text-muted lg:hidden">{{ countText }}</span>
|
||||
<div class="hidden items-center gap-3 lg:flex">
|
||||
<AppSwitch
|
||||
v-model="previewOn"
|
||||
:label="t('Preview')"
|
||||
:ui="{
|
||||
root: 'items-center gap-2',
|
||||
wrapper: 'ms-0',
|
||||
label: 'text-sm font-normal text-toned',
|
||||
}"
|
||||
/>
|
||||
<UButton
|
||||
data-testid="save-button"
|
||||
icon="i-lucide-save"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-8 gap-1.5 px-3"
|
||||
:disabled="!saveEnabled"
|
||||
@click="emit('save')"
|
||||
>
|
||||
{{ canPublish ? t('Save') : t('Save draft') }}
|
||||
</UButton>
|
||||
<PublishControl
|
||||
v-if="canPublish"
|
||||
class="h-8 gap-1.5 px-3"
|
||||
:online="article.is_online"
|
||||
:dirty="state === 'dirty'"
|
||||
:pending="publishPending"
|
||||
:disabled="publishDisabled"
|
||||
@publish="emit('publish')"
|
||||
@unpublish="emit('unpublish')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
class="h-8 gap-1.5 px-3"
|
||||
:disabled="!canDelete"
|
||||
:title="canDelete ? undefined : deleteReason"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
{{ t('Delete') }}
|
||||
</UButton>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<slot name="meta" />
|
||||
</div>
|
||||
<div class="hidden shrink-0 lg:flex">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import EditorLanguageRow from '@/components/EditorLanguageRow.vue'
|
||||
import { useIsDesktop } from '@/composables/useIsDesktop'
|
||||
import { MENU_FILTER_THRESHOLD, filterRows, type VersionRow } from '@/lib/editorVersions'
|
||||
|
||||
const props = defineProps<{
|
||||
rows: VersionRow[]
|
||||
name: string
|
||||
code: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ choose: [row: VersionRow] }>()
|
||||
|
||||
const MENU_ITEM_SELECTOR = '[role="menuitem"]'
|
||||
const CURRENT_ROW_SELECTOR = `${MENU_ITEM_SELECTOR} [aria-current="true"]`
|
||||
const CURRENT_ROW_CLASS = 'bg-(--rc-sand) text-(--rc-on-sand)'
|
||||
|
||||
const desktop = useIsDesktop('64rem')
|
||||
const open = ref(false)
|
||||
const query = ref('')
|
||||
const trigger = ref<{ $el: HTMLElement } | null>(null)
|
||||
const desktopMenuTop = ref<HTMLElement | null>(null)
|
||||
const sheetMenu = ref<HTMLElement | null>(null)
|
||||
|
||||
const filterable = computed(() => props.rows.length > MENU_FILTER_THRESHOLD)
|
||||
const visibleRows = computed(() => filterRows(props.rows, query.value))
|
||||
const triggerLabel = computed(() => t('Language: {0}. Switch version', [props.name]))
|
||||
|
||||
const dropdownItems = computed(() =>
|
||||
visibleRows.value.map((row) => ({
|
||||
slot: 'row',
|
||||
row,
|
||||
class: [
|
||||
'min-h-10 items-center rounded-none px-3 py-1.5 max-lg:min-h-13',
|
||||
row.current ? CURRENT_ROW_CLASS : row.addTranslation ? 'text-toned' : '',
|
||||
],
|
||||
onSelect: () => emit('choose', row),
|
||||
})),
|
||||
)
|
||||
|
||||
function focusCurrentRow(menu: Element | null | undefined) {
|
||||
const current = menu
|
||||
?.querySelector(CURRENT_ROW_SELECTOR)
|
||||
?.closest<HTMLElement>(MENU_ITEM_SELECTOR)
|
||||
const first = menu?.querySelector<HTMLElement>(MENU_ITEM_SELECTOR)
|
||||
;(current ?? first)?.focus()
|
||||
}
|
||||
|
||||
function focusDesktopMenu() {
|
||||
focusCurrentRow(desktopMenuTop.value?.closest('[role="menu"]'))
|
||||
}
|
||||
|
||||
watch(open, (value) => {
|
||||
if (value) {
|
||||
query.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(sheetMenu, (menu) => focusCurrentRow(menu), { flush: 'post' })
|
||||
watch(desktopMenuTop, focusDesktopMenu, { flush: 'post' })
|
||||
|
||||
function closeFromFilter() {
|
||||
open.value = false
|
||||
trigger.value?.$el?.focus()
|
||||
}
|
||||
|
||||
function choose(row: VersionRow) {
|
||||
open.value = false
|
||||
emit('choose', row)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDropdownMenu
|
||||
v-if="desktop"
|
||||
v-model:open="open"
|
||||
:items="[dropdownItems]"
|
||||
:content="{ align: 'start', loop: true }"
|
||||
:ui="{
|
||||
content: 'w-100 max-h-105 rounded-none p-0 shadow-(--shadow-popover)',
|
||||
viewport: 'max-h-80 p-0',
|
||||
group: 'p-0',
|
||||
item: 'rounded-none',
|
||||
}"
|
||||
>
|
||||
<UButton
|
||||
ref="trigger"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-languages"
|
||||
trailing-icon="i-lucide-chevrons-up-down"
|
||||
:aria-label="triggerLabel"
|
||||
class="h-8 max-w-65 gap-2 border bg-default px-2.5 font-medium text-highlighted ring-0"
|
||||
:class="open ? 'border-primary' : 'border-accented'"
|
||||
:ui="{ leadingIcon: 'size-4', trailingIcon: 'size-3.5 ms-0' }"
|
||||
>
|
||||
<span class="whitespace-nowrap">{{ name }}</span>
|
||||
<span class="font-mono text-xs text-muted">{{ code }}</span>
|
||||
</UButton>
|
||||
<template #content-top>
|
||||
<div ref="desktopMenuTop" class="flex flex-col gap-1.5 px-3 pt-2.5 pb-1.5">
|
||||
<span class="text-2xs font-semibold tracking-wide text-muted uppercase">{{
|
||||
t('Versions you can open')
|
||||
}}</span>
|
||||
<UInput
|
||||
v-if="filterable"
|
||||
v-model="query"
|
||||
:placeholder="t('Find a language')"
|
||||
:aria-label="t('Find a language')"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-8 border border-accented bg-default text-sm ring-0' }"
|
||||
@keydown.stop
|
||||
@keydown.esc="closeFromFilter"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #row="{ item }">
|
||||
<EditorLanguageRow :row="(item as unknown as { row: VersionRow }).row" />
|
||||
</template>
|
||||
<template #content-bottom>
|
||||
<div class="flex gap-2 border-t border-default px-3 py-2.5 text-xs/4.5 text-muted">
|
||||
<UIcon name="i-lucide-info" class="mt-0.5 size-3.5 shrink-0" />
|
||||
{{ t('Switching opens that version. The interface language stays the same.') }}
|
||||
</div>
|
||||
</template>
|
||||
</UDropdownMenu>
|
||||
|
||||
<template v-else>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-languages"
|
||||
trailing-icon="i-lucide-chevrons-up-down"
|
||||
:aria-label="triggerLabel"
|
||||
class="h-11 max-w-65 gap-2 border border-accented bg-default px-2.5 font-medium text-highlighted ring-0"
|
||||
:ui="{ leadingIcon: 'size-4', trailingIcon: 'size-3.5 ms-0' }"
|
||||
@click="open = true"
|
||||
>
|
||||
<span class="whitespace-nowrap">{{ name }}</span>
|
||||
<span class="font-mono text-xs text-muted">{{ code }}</span>
|
||||
</UButton>
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:overlay="false"
|
||||
:title="t('Versions you can open')"
|
||||
:description="t('Versions you can open')"
|
||||
:ui="{
|
||||
content:
|
||||
'max-h-7/10 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: 'sr-only',
|
||||
body: 'flex min-h-0 flex-col p-0 sm:p-0',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-1.5 px-3 pt-2.5 pb-1.5">
|
||||
<span class="text-2xs font-semibold tracking-wide text-muted uppercase">{{
|
||||
t('Versions you can open')
|
||||
}}</span>
|
||||
<UInput
|
||||
v-if="filterable"
|
||||
v-model="query"
|
||||
:placeholder="t('Find a language')"
|
||||
:aria-label="t('Find a language')"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 border border-accented bg-default text-sm ring-0' }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref="sheetMenu"
|
||||
role="menu"
|
||||
:aria-label="t('Versions of this article')"
|
||||
class="flex min-h-0 flex-col overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
v-for="row in visibleRows"
|
||||
:key="row.key"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="flex min-h-13 items-center px-3 py-1.5 text-start"
|
||||
:class="
|
||||
row.current ? CURRENT_ROW_CLASS : row.addTranslation ? 'text-toned' : ''
|
||||
"
|
||||
@click="choose(row)"
|
||||
>
|
||||
<EditorLanguageRow :row="row" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2 border-t border-default px-3 py-2.5 text-xs/4.5 text-muted">
|
||||
<UIcon name="i-lucide-info" class="mt-0.5 size-3.5 shrink-0" />
|
||||
{{ t('Switching opens that version. The interface language stays the same.') }}
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import LocaleBadge from '@/components/localization/LocaleBadge.vue'
|
||||
import type { VersionRow } from '@/lib/editorVersions'
|
||||
|
||||
defineProps<{ row: VersionRow }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex w-full items-center gap-2.5" :aria-current="row.current ? 'true' : undefined">
|
||||
<span class="flex w-4 shrink-0">
|
||||
<UIcon v-if="row.current" name="i-lucide-check" class="size-4" />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 flex-col">
|
||||
<span
|
||||
class="truncate text-base"
|
||||
:class="row.current ? 'font-semibold' : 'font-medium'"
|
||||
>{{ row.name }}</span
|
||||
>
|
||||
<span class="font-mono text-xs" :class="row.current ? '' : 'text-muted'">{{
|
||||
row.code
|
||||
}}</span>
|
||||
<span v-if="row.title" class="sr-only">{{ row.title }}</span>
|
||||
</span>
|
||||
<span v-if="row.addTranslation" class="inline-flex items-center gap-1 font-medium">
|
||||
<UIcon name="i-lucide-plus" class="size-3.5" />
|
||||
{{ t('Add translation') }}
|
||||
</span>
|
||||
<span v-else class="flex flex-wrap justify-end gap-1">
|
||||
<span v-if="row.readOnly" class="inline-flex items-center gap-1 text-xs text-muted">
|
||||
<UIcon name="i-lucide-lock" class="size-3" />
|
||||
{{ t('Read only') }}
|
||||
</span>
|
||||
<LocaleBadge
|
||||
:tone="row.online ? 'success' : 'neutral'"
|
||||
:icon="row.online ? 'i-lucide-circle-check' : 'i-lucide-pencil'"
|
||||
>
|
||||
{{ row.online ? t('Published') : t('Draft') }}
|
||||
</LocaleBadge>
|
||||
<LocaleBadge v-if="row.languageDisabled" tone="neutral" icon="i-lucide-eye-off">
|
||||
{{ t('Disabled') }}
|
||||
</LocaleBadge>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
|
||||
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||
|
||||
export type LeaveChoice = 'keep' | 'discard' | 'save'
|
||||
|
||||
const props = defineProps<{ language: string; target: string }>()
|
||||
|
||||
const emit = defineEmits<{ close: [choice: LeaveChoice] }>()
|
||||
|
||||
const description = computed(() =>
|
||||
t("Your changes to the {0} version aren't saved.", [props.language]),
|
||||
)
|
||||
|
||||
const CLOSE_BUTTON = {
|
||||
color: 'neutral',
|
||||
variant: 'ghost',
|
||||
class: 'size-11 justify-center p-0 text-muted md:size-7',
|
||||
} as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="true"
|
||||
:title="t('Leave without saving?')"
|
||||
:description="description"
|
||||
:close="CLOSE_BUTTON"
|
||||
:ui="{
|
||||
content: `${SHEET_CONTENT_CLASS} sm:top-25 sm:max-w-115`,
|
||||
close: 'end-6 top-8 md:top-5',
|
||||
header: 'min-h-0 items-start px-6 pt-5 pb-0 sm:px-6',
|
||||
title: 'pe-14 text-lg/6 font-semibold md:pe-10',
|
||||
description: 'mt-1 pe-14 text-sm text-pretty text-toned md:pe-10',
|
||||
body: 'hidden',
|
||||
footer: SHEET_FOOTER_CLASS,
|
||||
}"
|
||||
@update:open="(value: boolean) => !value && emit('close', 'keep')"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 md:flex-row md:flex-wrap md:justify-end">
|
||||
<LocaleButton block class="md:w-auto" @click="emit('close', 'keep')">
|
||||
{{ t('Keep editing') }}
|
||||
</LocaleButton>
|
||||
<LocaleButton
|
||||
kind="danger"
|
||||
block
|
||||
class="md:w-auto"
|
||||
@click="emit('close', 'discard')"
|
||||
>
|
||||
{{ t('Discard and open {0}', [target]) }}
|
||||
</LocaleButton>
|
||||
<LocaleButton kind="primary" block class="md:w-auto" @click="emit('close', 'save')">
|
||||
{{ t('Save and open {0}', [target]) }}
|
||||
</LocaleButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -1,59 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import PublishControl from '@/components/PublishControl.vue'
|
||||
import type { ArticleDraftState } from '@/composables/useArticleDraft'
|
||||
import EditorOverflowMenu from '@/components/EditorOverflowMenu.vue'
|
||||
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||
import { primaryLabel, unpublishLabel } from '@/lib/editorLabels'
|
||||
import type { ActionPlan } from '@/lib/editorActions'
|
||||
import type { OverflowItem } from '@/lib/editorOverflow'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
online: boolean
|
||||
state: ArticleDraftState
|
||||
saveEnabled: boolean
|
||||
publishPending: string | undefined
|
||||
publishDisabled: boolean
|
||||
canPublish?: boolean
|
||||
}>(),
|
||||
{ canPublish: true },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
details: []
|
||||
save: []
|
||||
publish: []
|
||||
unpublish: []
|
||||
defineProps<{
|
||||
hint: string
|
||||
plan: ActionPlan
|
||||
language: string | null
|
||||
offline: boolean
|
||||
overflow: OverflowItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ details: []; save: []; primary: []; unpublish: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden">
|
||||
<UButton
|
||||
icon="i-lucide-panel-right"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="size-11 justify-center"
|
||||
:aria-label="t('Details')"
|
||||
@click="emit('details')"
|
||||
/>
|
||||
<UButton
|
||||
data-testid="save-button-mobile"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 flex-1 justify-center"
|
||||
:disabled="!saveEnabled"
|
||||
@click="emit('save')"
|
||||
>
|
||||
{{ canPublish ? t('Save') : t('Save draft') }}
|
||||
</UButton>
|
||||
<PublishControl
|
||||
v-if="canPublish"
|
||||
class="h-11 flex-1 justify-center"
|
||||
hide-icon
|
||||
:online="online"
|
||||
:dirty="state === 'dirty'"
|
||||
:pending="publishPending"
|
||||
:disabled="publishDisabled"
|
||||
@publish="emit('publish')"
|
||||
@unpublish="emit('unpublish')"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-shrink-0 flex-col gap-2 border-t border-default bg-default px-4 py-3 lg:hidden"
|
||||
>
|
||||
<p v-if="hint" class="text-xs text-muted">{{ hint }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<LocaleButton
|
||||
icon="i-lucide-panel-right"
|
||||
icon-only
|
||||
:aria-label="t('Details')"
|
||||
@click="emit('details')"
|
||||
/>
|
||||
<LocaleButton
|
||||
v-if="plan.primary"
|
||||
data-testid="primary-action-mobile"
|
||||
kind="primary"
|
||||
block
|
||||
class="min-w-0 flex-1"
|
||||
:loading="plan.primary.busy"
|
||||
:class="plan.primary.busy ? '!opacity-40' : ''"
|
||||
:aria-busy="plan.primary.busy ? 'true' : undefined"
|
||||
:disabled="plan.primary.disabled || offline"
|
||||
@click="emit('primary')"
|
||||
>
|
||||
{{ primaryLabel(plan.primary.kind, language) }}
|
||||
</LocaleButton>
|
||||
<LocaleButton
|
||||
v-if="plan.saveButton"
|
||||
data-testid="save-button-mobile"
|
||||
:disabled="plan.saveButton.disabled || offline"
|
||||
@click="emit('save')"
|
||||
>
|
||||
{{ t('Save') }}
|
||||
</LocaleButton>
|
||||
<LocaleButton
|
||||
v-if="!plan.primary && !plan.saveButton && plan.unpublish"
|
||||
block
|
||||
class="min-w-0 flex-1"
|
||||
icon="i-lucide-eye-off"
|
||||
:disabled="offline"
|
||||
@click="emit('unpublish')"
|
||||
>
|
||||
{{ unpublishLabel(language) }}
|
||||
</LocaleButton>
|
||||
<EditorOverflowMenu v-if="overflow.length" :items="overflow" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
|
||||
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||
import type { EditorNotice } from '@/lib/editorNotices'
|
||||
|
||||
defineProps<{ notices: EditorNotice[] }>()
|
||||
|
||||
const emit = defineEmits<{ retryPublish: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="notices.length"
|
||||
class="flex shrink-0 flex-col gap-2 border-b border-default px-4 py-3 lg:px-6"
|
||||
>
|
||||
<LocaleAlert
|
||||
v-for="notice in notices"
|
||||
:key="notice.key"
|
||||
:tone="notice.tone"
|
||||
:icon="notice.icon"
|
||||
:title="notice.title"
|
||||
:description="notice.description"
|
||||
>
|
||||
<template v-if="notice.action" #actions>
|
||||
<LocaleButton small @click="emit('retryPublish')">{{
|
||||
t('Retry publish')
|
||||
}}</LocaleButton>
|
||||
</template>
|
||||
</LocaleAlert>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { useIsDesktop } from '@/composables/useIsDesktop'
|
||||
import type { OverflowItem } from '@/lib/editorOverflow'
|
||||
|
||||
const props = defineProps<{ items: OverflowItem[] }>()
|
||||
|
||||
const desktop = useIsDesktop('64rem')
|
||||
const open = ref(false)
|
||||
|
||||
const dropdownItems = computed(() =>
|
||||
props.items.map((item) => ({
|
||||
slot: 'entry',
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
color: item.danger ? ('error' as const) : undefined,
|
||||
disabled: item.disabled,
|
||||
reason: item.reason,
|
||||
class: item.disabled ? 'opacity-60' : '',
|
||||
onSelect: item.onSelect,
|
||||
})),
|
||||
)
|
||||
|
||||
function select(item: OverflowItem) {
|
||||
if (item.disabled) {
|
||||
return
|
||||
}
|
||||
open.value = false
|
||||
item.onSelect()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDropdownMenu
|
||||
v-if="desktop"
|
||||
:items="[dropdownItems]"
|
||||
:content="{ align: 'end' }"
|
||||
:ui="{
|
||||
content: 'w-80 rounded-none p-1 shadow-(--shadow-popover)',
|
||||
group: 'p-0',
|
||||
item: 'min-h-10 items-start gap-2.5 rounded-none px-3 py-2 text-base',
|
||||
itemLeadingIcon: 'size-4',
|
||||
}"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-ellipsis"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="size-8 justify-center border border-accented bg-default text-highlighted ring-0"
|
||||
:aria-label="t('More actions')"
|
||||
/>
|
||||
<template #entry="{ item }">
|
||||
<UIcon :name="item.icon as string" class="mt-0.5 size-4 shrink-0" />
|
||||
<span class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="font-medium">{{ item.label }}</span>
|
||||
<span v-if="item.reason" class="text-xs/4 font-normal text-muted">{{
|
||||
item.reason
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</UDropdownMenu>
|
||||
|
||||
<template v-else>
|
||||
<UButton
|
||||
icon="i-lucide-ellipsis"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="size-11 justify-center border border-accented bg-default text-highlighted ring-0"
|
||||
:aria-label="t('More actions')"
|
||||
@click="open = true"
|
||||
/>
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:overlay="false"
|
||||
:title="t('More actions')"
|
||||
:description="t('More actions')"
|
||||
:ui="{
|
||||
content:
|
||||
'max-h-none pt-3 pb-4 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: 'sr-only',
|
||||
body: 'p-0 sm:p-0',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div role="menu" :aria-label="t('More actions')" class="flex flex-col py-1">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
:aria-disabled="item.disabled ? 'true' : undefined"
|
||||
class="flex min-h-13 items-center gap-2.5 px-3 py-2 text-start text-md"
|
||||
:class="[
|
||||
item.danger ? 'text-error' : 'text-highlighted',
|
||||
item.disabled ? 'opacity-60' : '',
|
||||
]"
|
||||
@click="select(item)"
|
||||
>
|
||||
<UIcon :name="item.icon" class="size-4 shrink-0" />
|
||||
<span class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="font-medium">{{ item.label }}</span>
|
||||
<span v-if="item.reason" class="text-xs/4 font-normal text-muted">{{
|
||||
item.reason
|
||||
}}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,15 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{ headerless?: boolean }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div aria-busy="true" class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||
<div
|
||||
aria-busy="true"
|
||||
class="flex min-h-0 flex-1 flex-col lg:flex-row"
|
||||
:class="headerless ? 'bg-default [&_.animate-pulse]:bg-accented' : ''"
|
||||
>
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
v-if="!headerless"
|
||||
class="flex flex-shrink-0 items-center gap-2.5 border-b border-default bg-default px-6 py-3.5"
|
||||
>
|
||||
<UButton
|
||||
@@ -23,8 +30,10 @@ const emit = defineEmits<{
|
||||
<USkeleton class="h-5 w-80 rounded-none" data-testid="skeleton-title" />
|
||||
<USkeleton class="h-5.5 w-15 rounded-none" />
|
||||
</div>
|
||||
<div v-if="headerless" class="h-13.5 shrink-0 border-b border-default lg:h-10.25" />
|
||||
<div
|
||||
class="min-h-0 max-w-168 flex-1 space-y-3.5 px-12 py-10"
|
||||
class="min-h-0 max-w-168 flex-1 space-y-3.5"
|
||||
:class="headerless ? 'px-4 py-6 lg:px-12 lg:py-10' : 'px-12 py-10'"
|
||||
data-testid="skeleton-content"
|
||||
>
|
||||
<USkeleton class="h-3.5 w-full rounded-none" />
|
||||
@@ -37,6 +46,7 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
<div
|
||||
class="w-70 flex-shrink-0 space-y-2.5 border-default p-5 lg:border-l"
|
||||
:class="headerless ? 'max-lg:hidden' : ''"
|
||||
data-testid="skeleton-details"
|
||||
>
|
||||
<div class="flex flex-col gap-3 border-b border-default pb-4">
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, useId, watch } from 'vue'
|
||||
import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||
import { TITLE_MAX, titleLength, titleVerdict } from '@/lib/editorTitle'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
lang?: string
|
||||
readonly: boolean
|
||||
changed: boolean
|
||||
saving: boolean
|
||||
failed: boolean
|
||||
serverError: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ retry: [] }>()
|
||||
|
||||
const title = defineModel<string>({ required: true })
|
||||
|
||||
const inputId = useId()
|
||||
const helpId = useId()
|
||||
const focused = ref(false)
|
||||
const input = ref<{ textareaRef: HTMLTextAreaElement | null } | null>(null)
|
||||
|
||||
const verdict = computed(() => (props.serverError ? 'server' : titleVerdict(title.value)))
|
||||
const invalidText = computed(() => {
|
||||
const messages = {
|
||||
empty: t('Enter a title'),
|
||||
short: t('Use at least 3 characters'),
|
||||
long: t('Use 200 characters or fewer'),
|
||||
server: props.serverError,
|
||||
ok: '',
|
||||
}
|
||||
return messages[verdict.value]
|
||||
})
|
||||
const invalid = computed(() => verdict.value !== 'ok')
|
||||
const COUNTER_END_PADDING = 'pe-23'
|
||||
|
||||
const showsCounter = computed(
|
||||
() => focused.value || verdict.value === 'short' || verdict.value === 'long',
|
||||
)
|
||||
const showsAddressNote = computed(() => focused.value || props.changed)
|
||||
const errored = computed(() => invalid.value || props.failed)
|
||||
|
||||
const helper = computed(() => {
|
||||
if (invalidText.value) {
|
||||
return invalidText.value
|
||||
}
|
||||
if (props.failed) {
|
||||
return t("Couldn't save the title. Your text is kept.")
|
||||
}
|
||||
if (props.saving) {
|
||||
return t('Saving title…')
|
||||
}
|
||||
return showsAddressNote.value ? t("Changing the title doesn't change the address.") : ''
|
||||
})
|
||||
|
||||
const trailingIcon = computed(() => {
|
||||
if (props.saving) {
|
||||
return 'i-lucide-loader-circle'
|
||||
}
|
||||
return errored.value ? 'i-lucide-circle-x' : 'i-lucide-pencil'
|
||||
})
|
||||
|
||||
const borderClass = computed(() => {
|
||||
if (props.readonly) {
|
||||
return 'border-0 bg-transparent px-0 py-0 pe-5 lg:min-h-9'
|
||||
}
|
||||
if (errored.value) {
|
||||
return 'border-error'
|
||||
}
|
||||
return focused.value ? 'border-primary' : 'border-accented'
|
||||
})
|
||||
|
||||
async function remeasure() {
|
||||
await nextTick()
|
||||
input.value?.textareaRef?.dispatchEvent(new Event('input'))
|
||||
}
|
||||
|
||||
watch(showsCounter, remeasure)
|
||||
|
||||
function focusField() {
|
||||
input.value?.textareaRef?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ focus: focusField })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col" :class="readonly ? 'gap-0.5' : 'gap-1'">
|
||||
<label :for="inputId" class="text-xs/5 text-muted">{{ label }}</label>
|
||||
<UTextarea
|
||||
:id="inputId"
|
||||
ref="input"
|
||||
v-model="title"
|
||||
:lang="lang"
|
||||
:rows="1"
|
||||
autoresize
|
||||
:readonly="readonly"
|
||||
:aria-readonly="readonly ? 'true' : undefined"
|
||||
:aria-invalid="errored ? 'true' : undefined"
|
||||
:aria-describedby="readonly ? undefined : helpId"
|
||||
:trailing-icon="readonly ? 'i-lucide-lock' : trailingIcon"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
root: 'w-full',
|
||||
base: [
|
||||
'min-h-11 resize-none border bg-default px-2.5 py-0.5 !text-lead/7 font-semibold lg:!text-xl/7 text-highlighted ring-0 lg:min-h-10 lg:py-1.25',
|
||||
borderClass,
|
||||
showsCounter ? COUNTER_END_PADDING : '',
|
||||
focused && !errored && !changed
|
||||
? 'outline-2 outline-offset-1 outline-primary'
|
||||
: '',
|
||||
],
|
||||
trailing: ['items-start gap-2 pt-2.5', readonly ? 'pe-0' : 'pe-2.5'],
|
||||
trailingIcon: [
|
||||
'size-3.5',
|
||||
saving ? 'animate-spin' : '',
|
||||
errored ? 'text-error' : 'text-muted',
|
||||
],
|
||||
}"
|
||||
@keydown.enter.prevent
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
>
|
||||
<template v-if="showsCounter" #trailing>
|
||||
<CharacterCounter :value="titleLength(title)" :max="TITLE_MAX" variant="field" />
|
||||
<UIcon
|
||||
:name="trailingIcon"
|
||||
class="size-3.5"
|
||||
:class="[saving ? 'animate-spin' : '', errored ? 'text-error' : 'text-muted']"
|
||||
/>
|
||||
</template>
|
||||
</UTextarea>
|
||||
<div
|
||||
v-if="!readonly"
|
||||
:id="helpId"
|
||||
:role="invalid ? 'alert' : undefined"
|
||||
class="flex flex-wrap items-center gap-2 text-xs/4.5"
|
||||
:class="errored ? 'text-error' : 'text-muted'"
|
||||
>
|
||||
<UIcon v-if="errored" name="i-lucide-circle-x" class="size-3.5" />
|
||||
<span v-if="helper">{{ helper }}</span>
|
||||
<LocaleButton v-if="failed && !invalid" small @click="emit('retry')">
|
||||
{{ t('Try again') }}
|
||||
</LocaleButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||
|
||||
const emit = defineEmits<{ back: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div role="alert" class="flex flex-col items-start gap-4 p-4 lg:p-6">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h1 tabindex="-1" class="text-xl/7 font-semibold text-highlighted">
|
||||
{{ t("This version isn't available") }}
|
||||
</h1>
|
||||
<p class="text-toned">
|
||||
{{ t('It may have been deleted, or you may not have access.') }}
|
||||
</p>
|
||||
</div>
|
||||
<LocaleButton @click="emit('back')">{{ t('Back to Articles') }}</LocaleButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
kind: 'not-found' | 'error' | 'article-not-found'
|
||||
kind: 'not-found' | 'error'
|
||||
breadcrumb?: string
|
||||
}>(),
|
||||
{
|
||||
@@ -33,18 +33,6 @@ const copy = computed(() => {
|
||||
secondary: t('All articles'),
|
||||
}
|
||||
}
|
||||
if (props.kind === 'article-not-found') {
|
||||
return {
|
||||
get title() {
|
||||
return t("This article doesn't exist.")
|
||||
},
|
||||
get text() {
|
||||
return t('It may have been deleted, or the link is wrong.')
|
||||
},
|
||||
primary: t('All articles'),
|
||||
secondary: '',
|
||||
}
|
||||
}
|
||||
return {
|
||||
get title() {
|
||||
return t('Something went wrong')
|
||||
@@ -64,10 +52,6 @@ function onPrimary() {
|
||||
emit('retry')
|
||||
return
|
||||
}
|
||||
if (props.kind === 'article-not-found') {
|
||||
router.push('/article')
|
||||
return
|
||||
}
|
||||
router.push('/home')
|
||||
}
|
||||
|
||||
@@ -82,15 +66,9 @@ function onSecondary() {
|
||||
|
||||
<template>
|
||||
<div role="alert" class="flex max-w-140 flex-col gap-3.5 p-8">
|
||||
<nav v-if="kind !== 'article-not-found'" :aria-label="t('Breadcrumb')">
|
||||
<nav :aria-label="t('Breadcrumb')">
|
||||
<span>{{ breadcrumb ?? t('Home') }}</span>
|
||||
</nav>
|
||||
<span
|
||||
v-if="kind === 'article-not-found'"
|
||||
aria-hidden="true"
|
||||
class="font-mono text-sm text-muted"
|
||||
>404</span
|
||||
>
|
||||
<h1 tabindex="-1" class="text-2xl font-semibold text-highlighted">
|
||||
{{ copy.title }}
|
||||
</h1>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
online: boolean
|
||||
dirty: boolean
|
||||
pending?: string
|
||||
disabled?: boolean
|
||||
hideIcon?: boolean
|
||||
}>(),
|
||||
{
|
||||
pending: undefined,
|
||||
disabled: false,
|
||||
hideIcon: false,
|
||||
},
|
||||
)
|
||||
|
||||
defineEmits<{ publish: []; unpublish: [] }>()
|
||||
|
||||
const label = computed(() => {
|
||||
if (props.pending) {
|
||||
return props.pending
|
||||
}
|
||||
if (props.online) {
|
||||
return t('Unpublish')
|
||||
}
|
||||
if (props.dirty) {
|
||||
return t('Save and publish')
|
||||
}
|
||||
return t('Publish')
|
||||
})
|
||||
|
||||
const icon = computed(() => {
|
||||
if (props.hideIcon) {
|
||||
return undefined
|
||||
}
|
||||
if (props.online) {
|
||||
return 'i-lucide-globe-lock'
|
||||
}
|
||||
return 'i-lucide-globe'
|
||||
})
|
||||
|
||||
const variant = computed(() => (props.online ? 'outline' : 'solid'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton
|
||||
:color="online ? 'neutral' : 'primary'"
|
||||
:variant="variant"
|
||||
:icon="icon"
|
||||
:disabled="disabled"
|
||||
:loading="Boolean(pending)"
|
||||
@click="online ? $emit('unpublish') : $emit('publish')"
|
||||
>
|
||||
{{ label }}
|
||||
</UButton>
|
||||
</template>
|
||||
@@ -3,10 +3,14 @@ import { t } from '@/i18n'
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
state: 'clean' | 'dirty' | 'saving' | 'saved' | 'error' | 'offline'
|
||||
savedAt?: Date | null
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
state: 'clean' | 'dirty' | 'saving' | 'saved' | 'error' | 'offline'
|
||||
savedAt?: Date | null
|
||||
retryable?: boolean
|
||||
}>(),
|
||||
{ savedAt: null, retryable: true },
|
||||
)
|
||||
|
||||
defineEmits<{ retry: [] }>()
|
||||
|
||||
@@ -85,6 +89,7 @@ const savedText = computed(() => {
|
||||
<UIcon name="i-lucide-circle-alert" class="size-3.5" />
|
||||
<span>{{ t('Save failed') }}</span>
|
||||
<UButton
|
||||
v-if="retryable"
|
||||
size="xs"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
|
||||
@@ -43,6 +43,7 @@ async function mountArticleEditor(body = '<p>Hello</p>') {
|
||||
title: 'T',
|
||||
body,
|
||||
is_online: false,
|
||||
capabilities: ['read', 'edit', 'publish', 'delete'],
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
}),
|
||||
|
||||
@@ -74,6 +74,7 @@ function articleResponse(body: string) {
|
||||
title: 'How we price a bathroom renovation',
|
||||
body,
|
||||
is_online: false,
|
||||
capabilities: ['read', 'edit', 'publish', 'delete'],
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import PublishControl from '../PublishControl.vue'
|
||||
|
||||
function mountControl(props: { online: boolean; dirty: boolean; disabled?: boolean }) {
|
||||
return mount(PublishControl, {
|
||||
props,
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Draft, clean', () => {
|
||||
it('reads "Publish"', () => {
|
||||
const wrapper = mountControl({ online: false, dirty: false })
|
||||
|
||||
expect(wrapper.text()).toBe('Publish')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Draft, dirty', () => {
|
||||
it('reads "Save and publish"', () => {
|
||||
const wrapper = mountControl({ online: false, dirty: true })
|
||||
|
||||
expect(wrapper.text()).toBe('Save and publish')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Online', () => {
|
||||
it('reads "Unpublish" regardless of dirty state', () => {
|
||||
const wrapper = mountControl({ online: true, dirty: true })
|
||||
|
||||
expect(wrapper.text()).toBe('Unpublish')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled', () => {
|
||||
it('disables the button', () => {
|
||||
const wrapper = mountControl({
|
||||
online: false,
|
||||
dirty: false,
|
||||
disabled: true,
|
||||
})
|
||||
|
||||
expect((wrapper.find('button').element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
export type LocaleBadgeTone = 'neutral' | 'info' | 'success' | 'warning' | 'error'
|
||||
|
||||
defineProps<{
|
||||
tone: 'neutral' | 'info'
|
||||
tone: LocaleBadgeTone
|
||||
icon: string
|
||||
}>()
|
||||
|
||||
const TONE_CLASS: Record<LocaleBadgeTone, string> = {
|
||||
neutral: 'border-(--ui-bg-elevated) bg-elevated text-toned',
|
||||
info: 'border-(--rc-info-soft) bg-(--rc-info-soft) text-info',
|
||||
success: 'border-(--rc-success-soft) bg-(--rc-success-soft) text-success',
|
||||
warning: 'border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
|
||||
error: 'border-(--rc-error-soft) bg-(--rc-error-soft) text-error',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -11,13 +21,9 @@ defineProps<{
|
||||
variant="soft"
|
||||
:icon="icon"
|
||||
class="border"
|
||||
:class="
|
||||
tone === 'info'
|
||||
? 'border-(--rc-info-soft) bg-(--rc-info-soft) text-info'
|
||||
: 'border-(--ui-bg-elevated) bg-elevated text-toned'
|
||||
"
|
||||
:class="TONE_CLASS[tone]"
|
||||
:ui="{
|
||||
base: 'h-5 shrink-0 gap-1 px-1.5 py-0 text-xs whitespace-nowrap',
|
||||
base: 'h-5 shrink-0 gap-1 px-1.25 py-0 text-xs whitespace-nowrap',
|
||||
leadingIcon: 'size-3',
|
||||
}"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useArticleDraft, readMirroredDraft } from '@/composables/useArticleDraft'
|
||||
import { useArticleDraft } from '@/composables/useArticleDraft'
|
||||
import { readStoredDraft } from '@/lib/storedDraft'
|
||||
|
||||
const USER_ID = 7
|
||||
|
||||
function mockResponse(status: number, body: unknown = null) {
|
||||
return {
|
||||
@@ -19,7 +22,7 @@ afterEach(() => {
|
||||
describe('Loading an article without a mirrored draft starts clean', () => {
|
||||
it('sets the body and clean state from the given article', () => {
|
||||
setActivePinia(createPinia())
|
||||
const draft = useArticleDraft(12)
|
||||
const draft = useArticleDraft(12, USER_ID)
|
||||
|
||||
draft.load('<p>Original</p>', new Date('2026-09-26T11:02:00Z'))
|
||||
|
||||
@@ -32,8 +35,11 @@ describe('Loading an article without a mirrored draft starts clean', () => {
|
||||
describe('Loading an article with a mirrored draft restores it as dirty', () => {
|
||||
it('prefers the sessionStorage body over the loaded one', () => {
|
||||
setActivePinia(createPinia())
|
||||
sessionStorage.setItem('renewcms:article-draft:12', '<p>Restored</p>')
|
||||
const draft = useArticleDraft(12)
|
||||
sessionStorage.setItem(
|
||||
'renewcms:article-draft:7:12',
|
||||
JSON.stringify({ body: '<p>Restored</p>', mirrored_at: '2026-09-26T14:18:00Z' }),
|
||||
)
|
||||
const draft = useArticleDraft(12, USER_ID)
|
||||
|
||||
draft.load('<p>Original</p>', new Date('2026-09-26T11:02:00Z'))
|
||||
|
||||
@@ -45,7 +51,7 @@ describe('Loading an article with a mirrored draft restores it as dirty', () =>
|
||||
describe('Editing moves the state to dirty', () => {
|
||||
it('updates the body and sets state to dirty', () => {
|
||||
setActivePinia(createPinia())
|
||||
const draft = useArticleDraft(12)
|
||||
const draft = useArticleDraft(12, USER_ID)
|
||||
draft.load('<p>Original</p>', new Date())
|
||||
|
||||
draft.edit('<p>Changed</p>')
|
||||
@@ -61,7 +67,7 @@ describe('A successful save sets state saved and records the time', () => {
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse(200, { id: 12, body: '<p>Changed</p>' }))
|
||||
const draft = useArticleDraft(12)
|
||||
const draft = useArticleDraft(12, USER_ID)
|
||||
draft.load('<p>Original</p>', new Date())
|
||||
draft.edit('<p>Changed</p>')
|
||||
|
||||
@@ -77,7 +83,7 @@ describe('A 500 response sets state error and keeps the body', () => {
|
||||
it('resolves "error" without touching the body', async () => {
|
||||
setActivePinia(createPinia())
|
||||
global.fetch = vi.fn().mockResolvedValue(mockResponse(500))
|
||||
const draft = useArticleDraft(12)
|
||||
const draft = useArticleDraft(12, USER_ID)
|
||||
draft.load('<p>Original</p>', new Date())
|
||||
draft.edit('<p>Changed</p>')
|
||||
|
||||
@@ -93,7 +99,7 @@ describe('A 404 response keeps the body and does not enter the error state', ()
|
||||
it('resolves "not-found" and stays dirty', async () => {
|
||||
setActivePinia(createPinia())
|
||||
global.fetch = vi.fn().mockResolvedValue(mockResponse(404))
|
||||
const draft = useArticleDraft(12)
|
||||
const draft = useArticleDraft(12, USER_ID)
|
||||
draft.load('<p>Original</p>', new Date())
|
||||
draft.edit('<p>Changed</p>')
|
||||
|
||||
@@ -105,17 +111,17 @@ describe('A 404 response keeps the body and does not enter the error state', ()
|
||||
})
|
||||
|
||||
describe('A 401 response mirrors the body to sessionStorage', () => {
|
||||
it('resolves "unauthorized" and stores the body keyed by the article id', async () => {
|
||||
it('resolves "unauthorized" and stores the body keyed by the user and the article id', async () => {
|
||||
setActivePinia(createPinia())
|
||||
global.fetch = vi.fn().mockResolvedValue(mockResponse(401))
|
||||
const draft = useArticleDraft(12)
|
||||
const draft = useArticleDraft(12, USER_ID)
|
||||
draft.load('<p>Original</p>', new Date())
|
||||
draft.edit('<p>Paragraph added just before the session expired.</p>')
|
||||
|
||||
const outcome = await draft.save()
|
||||
|
||||
expect(outcome).toBe('unauthorized')
|
||||
expect(readMirroredDraft(12)).toBe(
|
||||
expect(readStoredDraft(USER_ID, 12)?.body).toBe(
|
||||
'<p>Paragraph added just before the session expired.</p>',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ const saved: ArticleSeo = {
|
||||
function loaded(sent = vi.fn()) {
|
||||
setActivePinia(createPinia())
|
||||
global.fetch = sent.mockResolvedValue(reply(200, {})) as unknown as typeof fetch
|
||||
const draft = useArticleDraft(12)
|
||||
const draft = useArticleDraft(12, 7)
|
||||
draft.load('<p>Body</p>', new Date('2026-09-26T11:02:00Z'), undefined, saved)
|
||||
return { draft, sent }
|
||||
}
|
||||
@@ -162,12 +162,14 @@ describe('The SEO fields are mirrored in sessionStorage', () => {
|
||||
const outcome = await draft.save()
|
||||
|
||||
expect(outcome).toBe('unauthorized')
|
||||
expect(JSON.parse(sessionStorage.getItem('renewcms:article-draft-seo:12')!)).toMatchObject({
|
||||
expect(
|
||||
JSON.parse(sessionStorage.getItem('renewcms:article-draft:7:12')!).seo,
|
||||
).toMatchObject({
|
||||
seo_title: 'Kept across the login',
|
||||
})
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const restored = useArticleDraft(12)
|
||||
const restored = useArticleDraft(12, 7)
|
||||
restored.load('<p>Body</p>', new Date(), undefined, saved)
|
||||
expect(restored.state.value).toBe('dirty')
|
||||
expect(restored.seo.value.seo_title).toBe('Kept across the login')
|
||||
@@ -178,11 +180,11 @@ describe('The SEO fields are mirrored in sessionStorage', () => {
|
||||
const { draft } = loaded()
|
||||
draft.editSeo({ seo_title: 'Soon saved' })
|
||||
draft.mirrorToStorage()
|
||||
expect(sessionStorage.getItem('renewcms:article-draft-seo:12')).not.toBeNull()
|
||||
expect(sessionStorage.getItem('renewcms:article-draft:7:12')).not.toBeNull()
|
||||
|
||||
await draft.save()
|
||||
|
||||
expect(sessionStorage.getItem('renewcms:article-draft-seo:12')).toBeNull()
|
||||
expect(sessionStorage.getItem('renewcms:article-draft:7:12')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps working in memory when sessionStorage throws', async () => {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { useEditorVersions } from '@/composables/useEditorVersions'
|
||||
import type { Article, ArticleCapability, CoverageEntry } from '@/lib/articleTypes'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
|
||||
const getLanguages = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useSiteLanguages', () => ({ useSiteLanguages: () => ({ getLanguages }) }))
|
||||
|
||||
const FULL: ArticleCapability[] = ['read', 'edit', 'publish', 'delete', 'create_translation']
|
||||
|
||||
function entry(id: number, lang: string, overrides: Partial<CoverageEntry> = {}): CoverageEntry {
|
||||
return {
|
||||
id,
|
||||
lang,
|
||||
title: `T${id}`,
|
||||
is_online: false,
|
||||
language_enabled: true,
|
||||
capabilities: FULL,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function articleWith(translations: CoverageEntry[], overrides: Partial<Article> = {}): Article {
|
||||
return {
|
||||
id: 12,
|
||||
title: 'T12',
|
||||
body: '',
|
||||
is_online: false,
|
||||
lang: 'fr',
|
||||
site_id: 1,
|
||||
capabilities: FULL,
|
||||
translations,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const sites = ref([{ id: 1, key: 'journal' }] as Site[])
|
||||
|
||||
function languages(...tags: [string, boolean][]) {
|
||||
return {
|
||||
languages: tags.map(([lang, enabled]) => ({ lang, enabled })),
|
||||
}
|
||||
}
|
||||
|
||||
function setup(article: Article | null) {
|
||||
return useEditorVersions(ref(article), sites, ref(FULL))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getLanguages.mockReset()
|
||||
getLanguages.mockResolvedValue(languages(['fr', true], ['en', true]))
|
||||
})
|
||||
|
||||
describe('The editor language versions', () => {
|
||||
it('is not multilingual with a single enabled language and a single translation', async () => {
|
||||
getLanguages.mockResolvedValue(languages(['fr', true]))
|
||||
const versions = setup(articleWith([entry(12, 'fr')]))
|
||||
await versions.loadLanguages()
|
||||
|
||||
expect(versions.multilingual.value).toBe(false)
|
||||
expect(versions.sentenceName.value).toBeNull()
|
||||
expect(versions.rows.value).toEqual([])
|
||||
})
|
||||
|
||||
it('is multilingual with two translations and lists the siblings', () => {
|
||||
const versions = setup(articleWith([entry(12, 'fr'), entry(10, 'en', { is_online: true })]))
|
||||
|
||||
expect(versions.multilingual.value).toBe(true)
|
||||
expect(versions.siblings.value.map((sibling) => sibling.id)).toEqual([10])
|
||||
expect(versions.onlineSiblingNames.value).toHaveLength(1)
|
||||
expect(versions.rows.value.map((row) => row.lang)).toEqual(['fr', 'en'])
|
||||
})
|
||||
|
||||
it('offers a missing enabled language as a translation to add', async () => {
|
||||
getLanguages.mockResolvedValue(languages(['fr', true], ['en', true], ['de', true]))
|
||||
const versions = setup(articleWith([entry(12, 'fr'), entry(10, 'en')]))
|
||||
await versions.loadLanguages()
|
||||
|
||||
expect(
|
||||
versions.rows.value.filter((row) => row.addTranslation).map((row) => row.lang),
|
||||
).toEqual(['de'])
|
||||
})
|
||||
|
||||
it('flags a disabled language on the current version', () => {
|
||||
const versions = setup(
|
||||
articleWith([entry(12, 'fr', { language_enabled: false }), entry(10, 'en')]),
|
||||
)
|
||||
|
||||
expect(versions.languageDisabled.value).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to no languages when loading them fails', async () => {
|
||||
getLanguages.mockRejectedValue(new Error('offline'))
|
||||
const versions = setup(articleWith([entry(12, 'fr')]))
|
||||
|
||||
await versions.loadLanguages()
|
||||
|
||||
expect(versions.multilingual.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not load languages for an article without a language tag', async () => {
|
||||
const versions = setup(articleWith([], { lang: undefined }))
|
||||
|
||||
await versions.loadLanguages()
|
||||
|
||||
expect(getLanguages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks a translation online and retitles it in place', () => {
|
||||
const versions = setup(articleWith([entry(12, 'fr'), entry(10, 'en')]))
|
||||
|
||||
versions.markOnline(10, true)
|
||||
versions.markTitle(10, 'New')
|
||||
|
||||
expect(versions.siblings.value[0]).toMatchObject({ is_online: true, title: 'New' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick, watch } from 'vue'
|
||||
import { announce, liveMessage } from '@/composables/useLiveAnnouncement'
|
||||
|
||||
describe('The live announcement', () => {
|
||||
it('exposes the announced text', async () => {
|
||||
announce('Editing French version')
|
||||
await nextTick()
|
||||
|
||||
expect(liveMessage.value).toBe('Editing French version')
|
||||
})
|
||||
|
||||
it('clears the text first so an identical message changes the region again', async () => {
|
||||
announce('Saved')
|
||||
await nextTick()
|
||||
const seen: string[] = []
|
||||
const stop = watchValues(seen)
|
||||
|
||||
announce('Saved')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
stop()
|
||||
|
||||
expect(seen).toEqual(['', 'Saved'])
|
||||
})
|
||||
})
|
||||
|
||||
function watchValues(seen: string[]) {
|
||||
return watch(liveMessage, (value) => seen.push(value), { flush: 'sync' })
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import router from '@/router'
|
||||
import { useShellLogout } from '@/composables/useShellSession'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { clearStashedArticle, stashArticle, takeStashedArticle } from '@/lib/prefetchedArticle'
|
||||
|
||||
const request = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: () => ({ request }) }
|
||||
})
|
||||
|
||||
beforeEach(() => request.mockReset().mockResolvedValue(undefined))
|
||||
afterEach(() => {
|
||||
sessionStorage.clear()
|
||||
clearStashedArticle()
|
||||
})
|
||||
|
||||
function mountLogout() {
|
||||
let logout!: () => Promise<void>
|
||||
mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
logout = useShellLogout().logout
|
||||
return () => null
|
||||
},
|
||||
}),
|
||||
{ global: { plugins: [router] } },
|
||||
)
|
||||
return logout
|
||||
}
|
||||
|
||||
describe('An explicit logout removes the stored drafts of that user only', () => {
|
||||
it('removes every draft of user 7 and keeps the drafts of user 8', async () => {
|
||||
setActivePinia(createPinia())
|
||||
useSessionStore().session = { id: 7, verified: true, displayName: '' }
|
||||
sessionStorage.setItem('renewcms:article-draft:7:12', '{}')
|
||||
sessionStorage.setItem('renewcms:article-draft:7:30', '{}')
|
||||
sessionStorage.setItem('renewcms:article-draft:8:12', '{}')
|
||||
const logout = mountLogout()
|
||||
|
||||
await logout()
|
||||
|
||||
expect(sessionStorage.getItem('renewcms:article-draft:7:12')).toBeNull()
|
||||
expect(sessionStorage.getItem('renewcms:article-draft:7:30')).toBeNull()
|
||||
expect(sessionStorage.getItem('renewcms:article-draft:8:12')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Logout cleans up even when the request fails', () => {
|
||||
it('still removes the drafts, the prefetched article and the session', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { id: 7, verified: true, displayName: '' }
|
||||
sessionStorage.setItem('renewcms:article-draft:7:12', '{}')
|
||||
stashArticle({
|
||||
id: 12,
|
||||
title: 'T',
|
||||
body: '',
|
||||
is_online: false,
|
||||
capabilities: ['read'],
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
})
|
||||
request.mockRejectedValueOnce(new Error('network'))
|
||||
const logout = mountLogout()
|
||||
|
||||
await logout()
|
||||
|
||||
expect(sessionStorage.getItem('renewcms:article-draft:7:12')).toBeNull()
|
||||
expect(takeStashedArticle(12)).toBeNull()
|
||||
expect(session.session).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
|
||||
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
||||
|
||||
const open = vi.fn()
|
||||
|
||||
vi.mock('@nuxt/ui/composables', () => ({
|
||||
useOverlay: () => ({ create: () => ({ open }) }),
|
||||
useToast: () => ({ add: vi.fn(), toasts: { value: [] } }),
|
||||
}))
|
||||
|
||||
let dirty = true
|
||||
const onDiscard = vi.fn()
|
||||
|
||||
const Editor = defineComponent({
|
||||
setup() {
|
||||
useUnsavedChangesGuard({ isDirty: () => dirty, title: () => 'Draft', onDiscard })
|
||||
return () => null
|
||||
},
|
||||
})
|
||||
|
||||
async function mountAt(path: string) {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/article/:id/edit', component: Editor },
|
||||
{ path: '/article', component: { render: () => null } },
|
||||
],
|
||||
})
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
mount({ render: () => h(RouterView) }, { global: { plugins: [router] } })
|
||||
return router
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
open.mockReset()
|
||||
onDiscard.mockReset()
|
||||
dirty = true
|
||||
})
|
||||
|
||||
describe('The unsaved changes guard', () => {
|
||||
it('asks before leaving the page while the draft is dirty', async () => {
|
||||
open.mockResolvedValue(true)
|
||||
const router = await mountAt('/article/1/edit')
|
||||
|
||||
await router.push('/article')
|
||||
|
||||
expect(open).toHaveBeenCalledOnce()
|
||||
expect(router.currentRoute.value.path).toBe('/article/1/edit')
|
||||
})
|
||||
|
||||
it('asks before a param-only navigation to another article', async () => {
|
||||
open.mockResolvedValue(true)
|
||||
const router = await mountAt('/article/1/edit')
|
||||
|
||||
await router.push('/article/2/edit')
|
||||
|
||||
expect(open).toHaveBeenCalledOnce()
|
||||
expect(router.currentRoute.value.path).toBe('/article/1/edit')
|
||||
})
|
||||
|
||||
it('discards and follows a param-only navigation when the user chooses to discard', async () => {
|
||||
open.mockResolvedValue(false)
|
||||
const router = await mountAt('/article/1/edit')
|
||||
|
||||
await router.push('/article/2/edit')
|
||||
|
||||
expect(onDiscard).toHaveBeenCalledOnce()
|
||||
expect(router.currentRoute.value.path).toBe('/article/2/edit')
|
||||
})
|
||||
|
||||
it('does not ask when nothing is unsaved', async () => {
|
||||
dirty = false
|
||||
const router = await mountAt('/article/1/edit')
|
||||
|
||||
await router.push('/article/2/edit')
|
||||
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(router.currentRoute.value.path).toBe('/article/2/edit')
|
||||
})
|
||||
|
||||
it('does not ask when only the query changes', async () => {
|
||||
const router = await mountAt('/article/1/edit')
|
||||
|
||||
await router.push('/article/1/edit?tab=seo')
|
||||
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { useVersionSwitch } from '@/composables/useVersionSwitch'
|
||||
import { takeStashedArticle } from '@/lib/prefetchedArticle'
|
||||
import type { VersionRow } from '@/lib/editorVersions'
|
||||
|
||||
const push = vi.fn()
|
||||
const request = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
|
||||
vi.mock('@/composables/useApi', () => ({
|
||||
useApi: () => ({ request }),
|
||||
authRedirectPending: { value: false },
|
||||
}))
|
||||
vi.mock('@nuxt/ui/composables', () => ({
|
||||
useOverlay: () => ({ create: () => ({ open: () => Promise.resolve('save') }) }),
|
||||
useToast: () => ({ add: vi.fn(), toasts: { value: [] } }),
|
||||
}))
|
||||
|
||||
const row: VersionRow = {
|
||||
key: 'version-11',
|
||||
lang: 'de',
|
||||
name: 'German',
|
||||
code: 'DE',
|
||||
articleId: 11,
|
||||
title: 'T',
|
||||
current: false,
|
||||
online: false,
|
||||
readOnly: false,
|
||||
languageDisabled: false,
|
||||
addTranslation: false,
|
||||
}
|
||||
|
||||
const target = {
|
||||
id: 11,
|
||||
title: 'T',
|
||||
body: '',
|
||||
is_online: false,
|
||||
capabilities: ['read'],
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
function mountSwitch() {
|
||||
const allowLeave = vi.fn()
|
||||
const cancelLeave = vi.fn()
|
||||
let choose!: (row: VersionRow) => Promise<void>
|
||||
mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
choose = useVersionSwitch({
|
||||
currentName: () => 'French',
|
||||
hasUnsavedChanges: () => false,
|
||||
save: async () => 'saved',
|
||||
discard: () => undefined,
|
||||
allowLeave,
|
||||
cancelLeave,
|
||||
}).choose
|
||||
return () => null
|
||||
},
|
||||
}),
|
||||
)
|
||||
return { choose, allowLeave, cancelLeave }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
push.mockReset()
|
||||
request.mockReset()
|
||||
request.mockResolvedValue(target)
|
||||
})
|
||||
|
||||
describe('Switching to another version', () => {
|
||||
it('lets the leave through and stashes the fetched article before navigating', async () => {
|
||||
push.mockResolvedValue(undefined)
|
||||
const { choose, allowLeave, cancelLeave } = mountSwitch()
|
||||
|
||||
await choose(row)
|
||||
|
||||
expect(allowLeave).toHaveBeenCalledOnce()
|
||||
expect(cancelLeave).not.toHaveBeenCalled()
|
||||
expect(push).toHaveBeenCalledWith('/article/11/edit')
|
||||
expect(takeStashedArticle(11)?.id).toBe(11)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['throws', () => push.mockRejectedValue(new Error('boom'))],
|
||||
['is cancelled', () => push.mockResolvedValue({ type: 4 })],
|
||||
])(
|
||||
'restores the leave guard and clears the stash when the navigation %s',
|
||||
async (_, arrange) => {
|
||||
arrange()
|
||||
const { choose, cancelLeave } = mountSwitch()
|
||||
|
||||
await choose(row)
|
||||
|
||||
expect(cancelLeave).toHaveBeenCalledOnce()
|
||||
expect(takeStashedArticle(11)).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('never lets the leave through when the fetch fails', async () => {
|
||||
request.mockRejectedValue(new Error('offline'))
|
||||
const { choose, allowLeave } = mountSwitch()
|
||||
|
||||
await choose(row)
|
||||
|
||||
expect(allowLeave).not.toHaveBeenCalled()
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
expect(takeStashedArticle(11)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,13 @@ import type { Article, Byline } from '@/composables/useArticles'
|
||||
import { sameIds, toBylines } from '@/lib/articleMeta'
|
||||
import { EMPTY_SEO, seoChanges, seoOf, type ArticleSeo } from '@/lib/articleSeo'
|
||||
import { AUTHOR_CODES, SITE_CODES, codeOf, statusOf } from '@/lib/directoryErrors'
|
||||
import { TITLE_SERVER_MESSAGE } from '@/lib/editorTitle'
|
||||
import {
|
||||
readStoredDraft,
|
||||
removeStoredDraft,
|
||||
writeStoredDraft,
|
||||
type StoredDraft,
|
||||
} from '@/lib/storedDraft'
|
||||
|
||||
export type ArticleDraftBusinessState = 'clean' | 'dirty' | 'saving' | 'saved' | 'error'
|
||||
export type ArticleDraftState = ArticleDraftBusinessState | 'offline'
|
||||
@@ -16,86 +23,24 @@ export type SaveOutcome =
|
||||
| 'author-gone'
|
||||
| 'slug-taken'
|
||||
| 'invalid-url'
|
||||
| 'invalid-title'
|
||||
|
||||
export interface ArticleMeta {
|
||||
siteId: number | null
|
||||
authors: Byline[]
|
||||
}
|
||||
|
||||
function draftStorageKey(articleId: number) {
|
||||
return `renewcms:article-draft:${articleId}`
|
||||
}
|
||||
|
||||
function metaStorageKey(articleId: number) {
|
||||
return `renewcms:article-draft-meta:${articleId}`
|
||||
}
|
||||
|
||||
function seoStorageKey(articleId: number) {
|
||||
return `renewcms:article-draft-seo:${articleId}`
|
||||
}
|
||||
|
||||
const SLUG_TAKEN_CODE = 'slug_taken'
|
||||
const INVALID_URL_CODE = 'invalid_url'
|
||||
|
||||
function readStorage(key: string): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(key: string, value: string) {
|
||||
try {
|
||||
sessionStorage.setItem(key, value)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function removeStorage(key: string) {
|
||||
try {
|
||||
sessionStorage.removeItem(key)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function readMirroredMeta(articleId: number): ArticleMeta | null {
|
||||
const raw = readStorage(metaStorageKey(articleId))
|
||||
if (raw === null) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
site_id: number | null
|
||||
authors: Byline[]
|
||||
}
|
||||
return { siteId: parsed.site_id, authors: toBylines(parsed.authors) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readMirroredSeo(articleId: number, base: ArticleSeo): ArticleSeo | null {
|
||||
const raw = readStorage(seoStorageKey(articleId))
|
||||
if (raw === null) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return seoOf(JSON.parse(raw), base)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readMirroredDraft(articleId: number): string | null {
|
||||
return readStorage(draftStorageKey(articleId))
|
||||
}
|
||||
|
||||
export function useArticleDraft(articleId: number) {
|
||||
export function useArticleDraft(articleId: number, userId: number | null = null) {
|
||||
const { request } = useApi()
|
||||
|
||||
const title = ref('')
|
||||
const savedTitle = ref('')
|
||||
const titleServerError = ref('')
|
||||
const savingTitle = ref(false)
|
||||
const restoredAt = ref<Date | null>(null)
|
||||
const body = ref('')
|
||||
const bodyEdited = ref(false)
|
||||
const meta = ref<ArticleMeta>({ siteId: null, authors: [] })
|
||||
@@ -140,28 +85,29 @@ export function useArticleDraft(articleId: number) {
|
||||
return seoChanges(seo.value, savedSeo.value)
|
||||
}
|
||||
|
||||
function mirrorToStorage() {
|
||||
if (bodyEdited.value) {
|
||||
writeStorage(draftStorageKey(articleId), body.value)
|
||||
}
|
||||
if (siteChanged() || authorsChanged()) {
|
||||
writeStorage(
|
||||
metaStorageKey(articleId),
|
||||
JSON.stringify({
|
||||
site_id: meta.value.siteId,
|
||||
authors: meta.value.authors,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (Object.keys(seoPatch()).length > 0) {
|
||||
writeStorage(seoStorageKey(articleId), JSON.stringify(seo.value))
|
||||
}
|
||||
function titleChanged() {
|
||||
return title.value !== savedTitle.value
|
||||
}
|
||||
|
||||
function clearStorage() {
|
||||
removeStorage(draftStorageKey(articleId))
|
||||
removeStorage(metaStorageKey(articleId))
|
||||
removeStorage(seoStorageKey(articleId))
|
||||
function mirrorToStorage() {
|
||||
const draft: StoredDraft = { mirrored_at: new Date().toISOString() }
|
||||
if (titleChanged()) {
|
||||
draft.title = title.value
|
||||
}
|
||||
if (bodyEdited.value) {
|
||||
draft.body = body.value
|
||||
}
|
||||
if (siteChanged() || authorsChanged()) {
|
||||
draft.meta = { site_id: meta.value.siteId, authors: meta.value.authors }
|
||||
}
|
||||
if (Object.keys(seoPatch()).length > 0) {
|
||||
draft.seo = seo.value
|
||||
}
|
||||
writeStoredDraft(userId, articleId, draft)
|
||||
}
|
||||
|
||||
function discardStored() {
|
||||
removeStoredDraft(userId, articleId)
|
||||
}
|
||||
|
||||
function load(
|
||||
@@ -169,19 +115,28 @@ export function useArticleDraft(articleId: number) {
|
||||
initialSavedAt: Date,
|
||||
initialMeta?: ArticleMeta,
|
||||
initialSeo?: ArticleSeo,
|
||||
initialTitle = '',
|
||||
) {
|
||||
const mirrored = readMirroredDraft(articleId)
|
||||
const mirroredMeta = readMirroredMeta(articleId)
|
||||
const stored = readStoredDraft(userId, articleId)
|
||||
savedAt.value = initialSavedAt
|
||||
savedTitle.value = initialTitle
|
||||
title.value = stored?.title ?? initialTitle
|
||||
savedMeta.value = initialMeta ?? { siteId: null, authors: [] }
|
||||
meta.value = mirroredMeta ?? savedMeta.value
|
||||
meta.value = stored?.meta
|
||||
? { siteId: stored.meta.site_id, authors: toBylines(stored.meta.authors) }
|
||||
: savedMeta.value
|
||||
savedSeo.value = initialSeo ?? { ...EMPTY_SEO }
|
||||
const mirroredSeo = readMirroredSeo(articleId, savedSeo.value)
|
||||
seo.value = mirroredSeo ?? savedSeo.value
|
||||
bodyEdited.value = mirrored !== null
|
||||
body.value = mirrored ?? initialBody
|
||||
const restored = mirrored !== null || mirroredMeta !== null || mirroredSeo !== null
|
||||
businessState.value = restored ? 'dirty' : 'clean'
|
||||
seo.value = stored?.seo ? seoOf(stored.seo, savedSeo.value) : savedSeo.value
|
||||
bodyEdited.value = stored?.body !== undefined
|
||||
body.value = stored?.body ?? initialBody
|
||||
restoredAt.value = stored ? new Date(stored.mirrored_at) : null
|
||||
businessState.value = stored ? 'dirty' : 'clean'
|
||||
}
|
||||
|
||||
function editTitle(next: string) {
|
||||
title.value = next
|
||||
titleServerError.value = ''
|
||||
businessState.value = 'dirty'
|
||||
}
|
||||
|
||||
function edit(newBody: string) {
|
||||
@@ -213,6 +168,7 @@ export function useArticleDraft(articleId: number) {
|
||||
function savePayload() {
|
||||
return {
|
||||
...seoPatch(),
|
||||
...(titleChanged() ? { title: title.value } : {}),
|
||||
...(bodyEdited.value ? { body: body.value } : {}),
|
||||
...(siteChanged() ? { site_id: meta.value.siteId } : {}),
|
||||
...(authorsChanged()
|
||||
@@ -223,6 +179,10 @@ export function useArticleDraft(articleId: number) {
|
||||
|
||||
function rejectedOutcome(err: unknown): SaveOutcome | null {
|
||||
const code = codeOf(err)
|
||||
if (statusOf(err) === 400 && code === TITLE_SERVER_MESSAGE) {
|
||||
titleServerError.value = code
|
||||
return 'invalid-title'
|
||||
}
|
||||
if (statusOf(err) === 409 && code === SLUG_TAKEN_CODE) {
|
||||
return 'slug-taken'
|
||||
}
|
||||
@@ -242,6 +202,7 @@ export function useArticleDraft(articleId: number) {
|
||||
|
||||
async function save(): Promise<SaveOutcome> {
|
||||
businessState.value = 'saving'
|
||||
savingTitle.value = titleChanged()
|
||||
const payload = savePayload()
|
||||
try {
|
||||
const saved =
|
||||
@@ -253,14 +214,18 @@ export function useArticleDraft(articleId: number) {
|
||||
})
|
||||
: undefined
|
||||
businessState.value = 'saved'
|
||||
savingTitle.value = false
|
||||
savedAt.value = new Date()
|
||||
savedTitle.value = title.value
|
||||
restoredAt.value = null
|
||||
savedMeta.value = meta.value
|
||||
savedSeo.value = seoOf(saved, seo.value)
|
||||
seo.value = savedSeo.value
|
||||
bodyEdited.value = false
|
||||
clearStorage()
|
||||
discardStored()
|
||||
return 'saved'
|
||||
} catch (err) {
|
||||
savingTitle.value = false
|
||||
const rejected = rejectedOutcome(err)
|
||||
if (rejected) {
|
||||
businessState.value = 'dirty'
|
||||
@@ -286,16 +251,23 @@ export function useArticleDraft(articleId: number) {
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
titleServerError,
|
||||
savingTitle,
|
||||
restoredAt,
|
||||
body,
|
||||
meta,
|
||||
seo,
|
||||
savedSeo,
|
||||
bodyEdited,
|
||||
mirrorToStorage,
|
||||
discardStored,
|
||||
state,
|
||||
businessState,
|
||||
savedAt,
|
||||
load,
|
||||
edit,
|
||||
editTitle,
|
||||
editMeta,
|
||||
editSeo,
|
||||
markDirty,
|
||||
|
||||
@@ -1,26 +1,8 @@
|
||||
import type { SuggestionCode } from '@/lib/seoTypes'
|
||||
import type { ArticleSeo } from '@/lib/articleSeo'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { Byline } from '@/lib/articleTypes'
|
||||
import type { Article, ArticleCapability, Byline, CoverageEntry } from '@/lib/articleTypes'
|
||||
import type { ArticleStatus, ArticleTab, SEO_FILTER } from '@/lib/articleListQuery'
|
||||
|
||||
export type { Byline }
|
||||
|
||||
export interface Article extends Partial<ArticleSeo> {
|
||||
id: number
|
||||
title: string
|
||||
body: string
|
||||
is_online: boolean
|
||||
lang?: string
|
||||
site_id?: number
|
||||
created_by?: number
|
||||
authors?: Byline[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
seo_status?: string
|
||||
seo_dismissed?: SuggestionCode[]
|
||||
seo_suggestions?: number
|
||||
}
|
||||
export type { Article, ArticleCapability, Byline, CoverageEntry }
|
||||
|
||||
export type { ArticleStatus }
|
||||
|
||||
@@ -49,17 +31,6 @@ export interface ListArticlesParams {
|
||||
seo?: '' | typeof SEO_FILTER
|
||||
}
|
||||
|
||||
export type ArticleCapability = 'read' | 'edit' | 'publish' | 'delete' | 'create_translation'
|
||||
|
||||
export interface CoverageEntry {
|
||||
id: number
|
||||
lang: string
|
||||
title: string
|
||||
is_online: boolean
|
||||
language_enabled: boolean
|
||||
capabilities: ArticleCapability[]
|
||||
}
|
||||
|
||||
export interface StoryItem extends Article {
|
||||
story_id?: number
|
||||
story_updated_at: string
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import EditorActionDialog from '@/components/EditorActionDialog.vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import type { SaveOutcome } from '@/composables/useArticleDraft'
|
||||
import { statusOf, codeOf } from '@/lib/directoryErrors'
|
||||
import { joinNames } from '@/lib/nameList'
|
||||
|
||||
const LANGUAGE_DISABLED_CODE = 'language_disabled'
|
||||
|
||||
interface PublishingOptions {
|
||||
articleId: number
|
||||
language: () => string
|
||||
siblingNames: () => string[]
|
||||
onlineSiblingNames: () => string[]
|
||||
hasUnsavedChanges: () => boolean
|
||||
save: () => Promise<SaveOutcome>
|
||||
onUpdated: (updated: Article) => void
|
||||
onGone: () => void
|
||||
}
|
||||
|
||||
interface DialogCopy {
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel: string
|
||||
notice?: { title: string; description: string }
|
||||
}
|
||||
|
||||
export function useEditorPublishing(options: PublishingOptions) {
|
||||
const { request } = useApi()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
const publishing = ref(false)
|
||||
const publishFailed = ref(false)
|
||||
const languageBlocked = ref(false)
|
||||
|
||||
function confirm(copy: () => DialogCopy): Promise<boolean> {
|
||||
return overlay
|
||||
.create(EditorActionDialog, {
|
||||
props: {
|
||||
get title() {
|
||||
return copy().title
|
||||
},
|
||||
get description() {
|
||||
return copy().description
|
||||
},
|
||||
get confirmLabel() {
|
||||
return copy().confirmLabel
|
||||
},
|
||||
get notice() {
|
||||
return copy().notice
|
||||
},
|
||||
},
|
||||
})
|
||||
.open()
|
||||
.then(Boolean)
|
||||
}
|
||||
|
||||
function otherVersionsLine() {
|
||||
const names = options.siblingNames()
|
||||
if (names.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return names.length === 1
|
||||
? t("{0} isn't affected.", [names[0]])
|
||||
: t("{0} aren't affected.", [joinNames(names)])
|
||||
}
|
||||
|
||||
function publishCopy(): DialogCopy {
|
||||
const language = options.language()
|
||||
return {
|
||||
title: t('Publish {0}?', [language]),
|
||||
description:
|
||||
`${t('Only the {0} version goes online.', [language])} ${otherVersionsLine()}`.trim(),
|
||||
confirmLabel: t('Publish {0}', [language]),
|
||||
}
|
||||
}
|
||||
|
||||
function saveAndPublishCopy(): DialogCopy {
|
||||
const language = options.language()
|
||||
return {
|
||||
title: t('Save and publish {0}?', [language]),
|
||||
description: `${t(
|
||||
'Your unsaved changes to the {0} version are saved first, then only the {0} version goes online.',
|
||||
[language],
|
||||
)} ${otherVersionsLine()}`.trim(),
|
||||
confirmLabel: t('Save and publish {0}', [language]),
|
||||
}
|
||||
}
|
||||
|
||||
function stayingOnline() {
|
||||
const online = options.onlineSiblingNames()
|
||||
if (online.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return online.length === 1
|
||||
? t('{0} stays online.', [online[0]])
|
||||
: t('{0} stay online.', [joinNames(online)])
|
||||
}
|
||||
|
||||
function unpublishCopy(): DialogCopy {
|
||||
const language = options.language()
|
||||
const dirty = options.hasUnsavedChanges()
|
||||
const lead = dirty
|
||||
? t('The saved {0} version goes offline and becomes a draft.', [language])
|
||||
: t('The {0} version goes back to draft and stops being public.', [language])
|
||||
return {
|
||||
title: t('Unpublish {0}?', [language]),
|
||||
description: `${lead} ${stayingOnline()}`.trim(),
|
||||
confirmLabel: t('Unpublish {0}', [language]),
|
||||
notice: dirty
|
||||
? {
|
||||
title: t('Your edits stay in the editor'),
|
||||
description: t(
|
||||
'Your unsaved changes are kept here. Nothing is saved when you unpublish. Save them yourself when you are ready.',
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function othersLine() {
|
||||
return t("Other versions weren't changed.")
|
||||
}
|
||||
|
||||
function announceDone(title: string) {
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title,
|
||||
get description() {
|
||||
return othersLine()
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function announceFailure(title: string) {
|
||||
toast.add(() => ({
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title,
|
||||
get description() {
|
||||
return t('Nothing changed. Try again.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function announceSaveFailure() {
|
||||
toast.add(() => ({
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
get title() {
|
||||
return t("Couldn't save {0}", [options.language()])
|
||||
},
|
||||
get description() {
|
||||
return t('Nothing was published. Your changes are kept. Try again.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function isLanguageDisabled(err: unknown) {
|
||||
return statusOf(err) === 422 && codeOf(err) === LANGUAGE_DISABLED_CODE
|
||||
}
|
||||
|
||||
async function send(makeOnline: boolean) {
|
||||
publishing.value = true
|
||||
try {
|
||||
const updated = await request<Article>(
|
||||
`/v1/article/${options.articleId}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
|
||||
{ method: 'PATCH' },
|
||||
)
|
||||
publishFailed.value = false
|
||||
options.onUpdated(updated)
|
||||
announceDone(
|
||||
makeOnline
|
||||
? t('{0} published', [options.language()])
|
||||
: t('{0} unpublished', [options.language()]),
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (statusOf(err) === 404) {
|
||||
options.onGone()
|
||||
} else if (isLanguageDisabled(err)) {
|
||||
languageBlocked.value = true
|
||||
} else {
|
||||
announceFailure(
|
||||
makeOnline
|
||||
? t("Couldn't publish {0}", [options.language()])
|
||||
: t("Couldn't unpublish {0}", [options.language()]),
|
||||
)
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
publishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (await confirm(publishCopy)) {
|
||||
await send(true)
|
||||
}
|
||||
}
|
||||
|
||||
async function unpublish() {
|
||||
if (await confirm(unpublishCopy)) {
|
||||
await send(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function retryPublish() {
|
||||
await send(true)
|
||||
}
|
||||
|
||||
async function saveAndPublish() {
|
||||
if (!(await confirm(saveAndPublishCopy))) {
|
||||
return
|
||||
}
|
||||
const outcome = await options.save()
|
||||
if (outcome !== 'saved') {
|
||||
if (outcome === 'error') {
|
||||
announceSaveFailure()
|
||||
}
|
||||
return
|
||||
}
|
||||
const published = await send(true)
|
||||
publishFailed.value = !published && !languageBlocked.value
|
||||
}
|
||||
|
||||
function clearPublishFailure() {
|
||||
publishFailed.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
publishing,
|
||||
publishFailed,
|
||||
languageBlocked,
|
||||
publish,
|
||||
unpublish,
|
||||
retryPublish,
|
||||
saveAndPublish,
|
||||
clearPublishFailure,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
import { locale } from '@/i18n'
|
||||
import type { Article, ArticleCapability } from '@/composables/useArticles'
|
||||
import { useSiteLanguages } from '@/composables/useSiteLanguages'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { versionRows } from '@/lib/editorVersions'
|
||||
import { languageName, type SiteLanguage } from '@/lib/siteLanguages'
|
||||
import { nameInSentence } from '@/lib/storyList'
|
||||
|
||||
export function useEditorVersions(
|
||||
article: Ref<Article | null>,
|
||||
sites: Ref<Site[]>,
|
||||
capabilities: Ref<ArticleCapability[]>,
|
||||
) {
|
||||
const { getLanguages } = useSiteLanguages()
|
||||
const siteLanguages = ref<SiteLanguage[]>([])
|
||||
|
||||
const tag = computed(() => article.value?.lang ?? '')
|
||||
const translations = computed(() => article.value?.translations ?? [])
|
||||
const enabledLanguages = computed(() =>
|
||||
siteLanguages.value.filter((language) => language.enabled).map((language) => language.lang),
|
||||
)
|
||||
const multilingual = computed(
|
||||
() =>
|
||||
tag.value !== '' &&
|
||||
(enabledLanguages.value.length > 1 || translations.value.length > 1),
|
||||
)
|
||||
const displayName = computed(() => (tag.value ? languageName(tag.value, locale.value) : ''))
|
||||
const sentenceName = computed<string | null>(() =>
|
||||
multilingual.value ? nameInSentence(tag.value) : null,
|
||||
)
|
||||
const currentTranslation = computed(() =>
|
||||
translations.value.find((entry) => entry.id === article.value?.id),
|
||||
)
|
||||
const languageDisabled = computed(() => currentTranslation.value?.language_enabled === false)
|
||||
const siblings = computed(() =>
|
||||
translations.value.filter((entry) => entry.id !== article.value?.id),
|
||||
)
|
||||
const rows = computed(() =>
|
||||
multilingual.value
|
||||
? versionRows({
|
||||
translations: translations.value,
|
||||
currentId: article.value?.id ?? 0,
|
||||
capabilities: capabilities.value,
|
||||
enabledLanguages: enabledLanguages.value,
|
||||
})
|
||||
: [],
|
||||
)
|
||||
const siblingNames = computed(() => siblings.value.map((entry) => nameInSentence(entry.lang)))
|
||||
const onlineSiblingNames = computed(() =>
|
||||
siblings.value
|
||||
.filter((entry) => entry.is_online)
|
||||
.map((entry) => nameInSentence(entry.lang)),
|
||||
)
|
||||
|
||||
async function loadLanguages() {
|
||||
const current = article.value
|
||||
const key = sites.value.find((site) => site.id === current?.site_id)?.key
|
||||
if (!current?.lang || !key) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
siteLanguages.value = (await getLanguages(key)).languages
|
||||
} catch {
|
||||
siteLanguages.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function markOnline(id: number, online: boolean) {
|
||||
const current = article.value
|
||||
if (!current?.translations) {
|
||||
return
|
||||
}
|
||||
current.translations = current.translations.map((entry) =>
|
||||
entry.id === id ? { ...entry, is_online: online } : entry,
|
||||
)
|
||||
}
|
||||
|
||||
function markTitle(id: number, title: string) {
|
||||
const current = article.value
|
||||
if (!current?.translations) {
|
||||
return
|
||||
}
|
||||
current.translations = current.translations.map((entry) =>
|
||||
entry.id === id ? { ...entry, title } : entry,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
tag,
|
||||
displayName,
|
||||
sentenceName,
|
||||
multilingual,
|
||||
languageDisabled,
|
||||
siblings,
|
||||
rows,
|
||||
siblingNames,
|
||||
onlineSiblingNames,
|
||||
loadLanguages,
|
||||
markOnline,
|
||||
markTitle,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { nextTick, readonly, ref } from 'vue'
|
||||
|
||||
const message = ref('')
|
||||
|
||||
export const liveMessage = readonly(message)
|
||||
|
||||
export function announce(text: string) {
|
||||
message.value = ''
|
||||
nextTick(() => {
|
||||
message.value = text
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { clearStashedArticle } from '@/lib/prefetchedArticle'
|
||||
import { removeStoredDraftsOf } from '@/lib/storedDraft'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const LOGIN_PATH = '/login'
|
||||
@@ -31,11 +33,10 @@ export function useShellLogout() {
|
||||
|
||||
async function logout() {
|
||||
loggingOut.value = true
|
||||
try {
|
||||
await api.request('/v1/auth/logout', { method: 'POST' })
|
||||
} finally {
|
||||
loggingOut.value = false
|
||||
}
|
||||
await api.request('/v1/auth/logout', { method: 'POST' }).catch(() => undefined)
|
||||
loggingOut.value = false
|
||||
removeStoredDraftsOf(session.session?.id)
|
||||
clearStashedArticle()
|
||||
session.clear()
|
||||
await router.push(LOGIN_PATH)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedOverlay } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
@@ -7,12 +7,13 @@ import { authRedirectPending } from '@/composables/useApi'
|
||||
interface UnsavedChangesOptions {
|
||||
isDirty: () => boolean
|
||||
title: () => string
|
||||
onDiscard?: () => void
|
||||
}
|
||||
|
||||
export function useUnsavedChangesGuard({ isDirty, title }: UnsavedChangesOptions) {
|
||||
export function useUnsavedChangesGuard({ isDirty, title, onDiscard }: UnsavedChangesOptions) {
|
||||
const overlay = useLocalizedOverlay()
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
async function allowsLeaving() {
|
||||
if (authRedirectPending.value || !isDirty()) {
|
||||
return true
|
||||
}
|
||||
@@ -40,6 +41,13 @@ export function useUnsavedChangesGuard({ isDirty, title }: UnsavedChangesOptions
|
||||
},
|
||||
})
|
||||
.open()
|
||||
return !confirmed
|
||||
})
|
||||
if (confirmed) {
|
||||
return false
|
||||
}
|
||||
onDiscard?.()
|
||||
return true
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(allowsLeaving)
|
||||
onBeforeRouteUpdate((to, from) => (to.params.id === from.params.id ? true : allowsLeaving()))
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import EditorLeaveDialog, { type LeaveChoice } from '@/components/EditorLeaveDialog.vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import type { SaveOutcome } from '@/composables/useArticleDraft'
|
||||
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||
import { announce } from '@/composables/useLiveAnnouncement'
|
||||
import type { VersionRow } from '@/lib/editorVersions'
|
||||
import { clearStashedArticle, stashArticle } from '@/lib/prefetchedArticle'
|
||||
|
||||
interface VersionSwitchOptions {
|
||||
currentName: () => string
|
||||
hasUnsavedChanges: () => boolean
|
||||
save: () => Promise<SaveOutcome>
|
||||
discard: () => void
|
||||
allowLeave: () => void
|
||||
cancelLeave: () => void
|
||||
}
|
||||
|
||||
export function useVersionSwitch(options: VersionSwitchOptions) {
|
||||
const { request } = useApi()
|
||||
const router = useRouter()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
const loading = useDelayedSkeleton()
|
||||
const opening = ref(false)
|
||||
|
||||
function announceFailure(row: VersionRow) {
|
||||
toast.add(() => ({
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
get title() {
|
||||
return t("Couldn't open the {0} version", [row.name])
|
||||
},
|
||||
get description() {
|
||||
return t("You're still editing {0}. Try again.", [options.currentName()])
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async function open(row: VersionRow) {
|
||||
opening.value = true
|
||||
loading.start()
|
||||
try {
|
||||
const target = await request<Article>(`/v1/article/${row.articleId}?format=html`)
|
||||
stashArticle(target)
|
||||
options.allowLeave()
|
||||
const failure = await router.push(`/article/${row.articleId}/edit`)
|
||||
if (failure) {
|
||||
throw failure
|
||||
}
|
||||
announce(t('Editing {0} version', [row.name]))
|
||||
} catch {
|
||||
options.cancelLeave()
|
||||
clearStashedArticle()
|
||||
announceFailure(row)
|
||||
} finally {
|
||||
opening.value = false
|
||||
loading.stop()
|
||||
}
|
||||
}
|
||||
|
||||
function askToLeave(row: VersionRow): Promise<LeaveChoice> {
|
||||
return overlay
|
||||
.create(EditorLeaveDialog, {
|
||||
props: {
|
||||
get language() {
|
||||
return options.currentName()
|
||||
},
|
||||
get target() {
|
||||
return row.name
|
||||
},
|
||||
},
|
||||
})
|
||||
.open() as Promise<LeaveChoice>
|
||||
}
|
||||
|
||||
async function settleUnsaved(row: VersionRow) {
|
||||
const choice = await askToLeave(row)
|
||||
if (choice === 'discard') {
|
||||
options.discard()
|
||||
return true
|
||||
}
|
||||
return choice === 'save' && (await options.save()) === 'saved'
|
||||
}
|
||||
|
||||
async function choose(row: VersionRow) {
|
||||
if (row.current || row.addTranslation || row.articleId === null || opening.value) {
|
||||
return
|
||||
}
|
||||
if (options.hasUnsavedChanges() && !(await settleUnsaved(row))) {
|
||||
return
|
||||
}
|
||||
await open(row)
|
||||
}
|
||||
|
||||
return { choose, loadingVisible: loading.visible }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { editorMessages } from './editorMessages'
|
||||
import { messages } from './messages'
|
||||
|
||||
const SOURCES = [
|
||||
'components/EditorActionDialog.vue',
|
||||
'components/EditorActions.vue',
|
||||
'components/EditorLanguageMenu.vue',
|
||||
'components/EditorLanguageRow.vue',
|
||||
'components/EditorLeaveDialog.vue',
|
||||
'components/EditorMobileBar.vue',
|
||||
'components/EditorNotices.vue',
|
||||
'components/EditorOverflowMenu.vue',
|
||||
'components/EditorTitleField.vue',
|
||||
'components/EditorVersionGone.vue',
|
||||
'composables/useEditorPublishing.ts',
|
||||
'composables/useVersionSwitch.ts',
|
||||
'lib/editorLabels.ts',
|
||||
'lib/editorNotices.ts',
|
||||
'lib/editorOverflow.ts',
|
||||
'views/EditorView.vue',
|
||||
]
|
||||
|
||||
const SAME_IN_FRENCH = new Set(['Save', 'Draft', 'Title', 'Body', 'Articles'])
|
||||
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
|
||||
const NUMBERED = /\{\d+\}/g
|
||||
|
||||
function keysIn(source: string) {
|
||||
return [...source.matchAll(LITERAL)].map((match) =>
|
||||
(match[1] ?? match[2] ?? '').replace(/\\(['"\\])/g, '$1'),
|
||||
)
|
||||
}
|
||||
|
||||
describe('Editor messages', () => {
|
||||
const keys = SOURCES.flatMap((file) =>
|
||||
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
|
||||
file,
|
||||
key,
|
||||
})),
|
||||
)
|
||||
|
||||
it('finds the strings of the editor', () => {
|
||||
expect(keys.length).toBeGreaterThan(60)
|
||||
})
|
||||
|
||||
it.each(keys.map(({ file, key }) => [file, key]))(
|
||||
'%s: "%s" has a 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)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it.each(Object.entries(editorMessages))(
|
||||
'keeps the placeholders of "%s" in French',
|
||||
(english, french) => {
|
||||
expect([...french.matchAll(NUMBERED)].map((m) => m[0]).sort()).toEqual(
|
||||
[...english.matchAll(NUMBERED)].map((m) => m[0]).sort(),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
export const editorMessages = {
|
||||
'Language: {0}. Switch version': 'Langue : {0}. Changer de version',
|
||||
'Versions you can open': 'Versions que vous pouvez ouvrir',
|
||||
'Find a language': 'Rechercher une langue',
|
||||
'Switching opens that version. The interface language stays the same.':
|
||||
'Changer ouvre cette version. La langue de l’interface ne change pas.',
|
||||
'Versions of this article': 'Versions de cet article',
|
||||
'Add translation': 'Ajouter une traduction',
|
||||
"Your changes to the {0} version aren't saved.":
|
||||
'Vos modifications de la version {0} ne sont pas enregistrées.',
|
||||
'Discard and open {0}': 'Abandonner et ouvrir {0}',
|
||||
'Save and open {0}': 'Enregistrer et ouvrir {0}',
|
||||
'Retry publish': 'Réessayer la publication',
|
||||
'Enter a title': 'Saisissez un titre',
|
||||
'Use at least 3 characters': 'Utilisez au moins 3 caractères',
|
||||
'Use 200 characters or fewer': 'Utilisez 200 caractères au plus',
|
||||
"Couldn't save the title. Your text is kept.":
|
||||
'Impossible d’enregistrer le titre. Votre texte est conservé.',
|
||||
'Saving title…': 'Enregistrement du titre…',
|
||||
"Changing the title doesn't change the address.": 'Modifier le titre ne modifie pas l’adresse.',
|
||||
"This version isn't available": 'Cette version n’est pas disponible',
|
||||
'It may have been deleted, or you may not have access.':
|
||||
'Elle a peut-être été supprimée, ou vous n’y avez pas accès.',
|
||||
"{0} isn't affected.": '{0} n’est pas concerné.',
|
||||
"{0} aren't affected.": '{0} ne sont pas concernés.',
|
||||
'Publish {0}?': 'Publier {0} ?',
|
||||
'Only the {0} version goes online.': 'Seule la version {0} est mise en ligne.',
|
||||
'Save and publish {0}?': 'Enregistrer et publier {0} ?',
|
||||
'Your unsaved changes to the {0} version are saved first, then only the {0} version goes online.':
|
||||
'Vos modifications non enregistrées de la version {0} sont d’abord enregistrées, puis seule la version {0} est mise en ligne.',
|
||||
'Save and publish {0}': 'Enregistrer et publier {0}',
|
||||
'{0} stays online.': '{0} reste en ligne.',
|
||||
'{0} stay online.': '{0} restent en ligne.',
|
||||
'The saved {0} version goes offline and becomes a draft.':
|
||||
'La version {0} enregistrée passe hors ligne et redevient un brouillon.',
|
||||
'The {0} version goes back to draft and stops being public.':
|
||||
'La version {0} redevient un brouillon et n’est plus publique.',
|
||||
'Unpublish {0}?': 'Dépublier {0} ?',
|
||||
'Your edits stay in the editor': 'Vos modifications restent dans l’éditeur',
|
||||
'Your unsaved changes are kept here. Nothing is saved when you unpublish. Save them yourself when you are ready.':
|
||||
'Vos modifications non enregistrées sont conservées ici. Rien n’est enregistré quand vous dépubliez. Enregistrez-les vous-même quand vous êtes prêt.',
|
||||
"Other versions weren't changed.": 'Les autres versions n’ont pas été modifiées.',
|
||||
"Couldn't save {0}": 'Impossible d’enregistrer {0}',
|
||||
'Nothing was published. Your changes are kept. Try again.':
|
||||
'Rien n’a été publié. Vos modifications sont conservées. Réessayez.',
|
||||
'{0} unpublished': '{0} dépublié',
|
||||
"Couldn't publish {0}": 'Impossible de publier {0}',
|
||||
"Couldn't unpublish {0}": 'Impossible de dépublier {0}',
|
||||
"Couldn't open the {0} version": 'Impossible d’ouvrir la version {0}',
|
||||
"You're still editing {0}. Try again.": 'Vous modifiez toujours {0}. Réessayez.',
|
||||
'Editing {0} version': 'Modification de la version {0}',
|
||||
'Try saving again': 'Réessayer d’enregistrer',
|
||||
'Title ({0})': 'Titre ({0})',
|
||||
'Restored unsaved changes from {0}': 'Modifications non enregistrées restaurées de {0}',
|
||||
'You logged in again. Your {0} title and body were kept in this browser.':
|
||||
'Vous vous êtes reconnecté. Votre titre et votre texte en {0} ont été conservés dans ce navigateur.',
|
||||
'You logged in again. Your title and body were kept in this browser.':
|
||||
'Vous vous êtes reconnecté. Votre titre et votre texte ont été conservés dans ce navigateur.',
|
||||
"Couldn't save": 'Impossible d’enregistrer',
|
||||
'Your changes are kept here. Nothing was published. Try saving again.':
|
||||
'Vos modifications sont conservées ici. Rien n’a été publié. Réessayez d’enregistrer.',
|
||||
'Saved as a draft. Publishing failed. Try publishing again.':
|
||||
'Enregistré comme brouillon. La publication a échoué. Réessayez de publier.',
|
||||
'Your edits are saved. Only the publishing step failed.':
|
||||
'Vos modifications sont enregistrées. Seule l’étape de publication a échoué.',
|
||||
'{0} is disabled on this site. Re-enable it to publish.':
|
||||
'{0} est désactivé sur ce site. Réactivez-le pour publier.',
|
||||
'You can still edit and save this version. Ask an owner to re-enable {0}.':
|
||||
'Vous pouvez toujours modifier et enregistrer cette version. Demandez à un propriétaire de réactiver {0}.',
|
||||
"You can read this version. You can't edit it.":
|
||||
'Vous pouvez lire cette version. Vous ne pouvez pas la modifier.',
|
||||
'The source reference and the versions you can read stay available.':
|
||||
'La référence source et les versions que vous pouvez lire restent disponibles.',
|
||||
'You can publish this version but not edit it':
|
||||
'Vous pouvez publier cette version mais pas la modifier',
|
||||
'Fields are read-only. Publish stays available.':
|
||||
'Les champs sont en lecture seule. La publication reste disponible.',
|
||||
'Publishing needs the publish permission': 'Publier demande l’autorisation de publier',
|
||||
'Saving keeps it as a draft.': 'L’enregistrement la garde en brouillon.',
|
||||
'This version is online. Saving updates the live page right away.':
|
||||
'Cette version est en ligne. L’enregistrement met à jour la page publique immédiatement.',
|
||||
'Not available yet.': 'Pas encore disponible.',
|
||||
'Move article': 'Déplacer l’article',
|
||||
"You can't delete this article.": 'Vous ne pouvez pas supprimer cet article.',
|
||||
'{0} saved': '{0} enregistré',
|
||||
"{0} wasn't changed.": '{0} n’a pas été modifié.',
|
||||
"Each language has its own title. Changing it doesn't change the URL; edit the slug in Search and sharing.":
|
||||
'Chaque langue a son propre titre. Le modifier ne change pas l’URL ; modifiez le slug dans Recherche et partage.',
|
||||
"Changing the title doesn't change the URL; edit the slug in Search and sharing.":
|
||||
'Modifier le titre ne change pas l’URL ; modifiez le slug dans Recherche et partage.',
|
||||
} as const
|
||||
@@ -1,9 +1,11 @@
|
||||
import { articleListMessages } from './articleListMessages'
|
||||
import { editorMessages } from './editorMessages'
|
||||
import { localizationMessages } from './localizationMessages'
|
||||
|
||||
export const messages = {
|
||||
...localizationMessages,
|
||||
...articleListMessages,
|
||||
...editorMessages,
|
||||
'Not found': 'Introuvable',
|
||||
'Edit article': 'Modifier l’article',
|
||||
unselected: 'désélectionnée',
|
||||
@@ -241,8 +243,6 @@ export const messages = {
|
||||
'Clear search': 'Effacer la recherche',
|
||||
Title: 'Titre',
|
||||
'3 to 200 characters.': 'De 3 à 200 caractères.',
|
||||
"The title can't be changed after creation.":
|
||||
'Le titre ne peut pas être modifié après la création.',
|
||||
'It starts as a draft, so nothing appears on your site until you publish it.':
|
||||
'Il commence comme brouillon : rien n’apparaît sur votre site avant sa publication.',
|
||||
"It's saved as a draft. Titles can't be changed later, so check it now.":
|
||||
@@ -303,8 +303,6 @@ export const messages = {
|
||||
'Something went wrong on the server. Try again in a moment.':
|
||||
'Une erreur est survenue sur le serveur. Réessayez dans un instant.',
|
||||
'Live. Your website can read this article.': 'Publié. Votre site peut lire cet article.',
|
||||
"Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.":
|
||||
'Les titres ne peuvent pas être modifiés après la création d’un article. Pour changer de titre, créez un nouvel article et supprimez celui-ci.',
|
||||
'Back to Articles': 'Retour aux articles',
|
||||
'Back to Integration': 'Retour à l’intégration',
|
||||
'Your changes to "{0}" haven\'t been saved.':
|
||||
@@ -775,9 +773,6 @@ export const messages = {
|
||||
'Something went wrong. Try again.': 'Une erreur est survenue. Réessayez.',
|
||||
"This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.":
|
||||
'Cette page n’a pas pu se charger. Votre travail enregistré est conservé. Réessayez et, si le problème persiste, redémarrez le serveur RenewCMS.',
|
||||
"This article doesn't exist.": 'Cet article n’existe pas.',
|
||||
'It may have been deleted, or the link is wrong.':
|
||||
'Il a peut-être été supprimé ou le lien est incorrect.',
|
||||
Dismiss: 'Fermer',
|
||||
"You're offline. You can keep writing, but saving and publishing will wait until you're back online.":
|
||||
'Vous êtes hors ligne. Vous pouvez continuer à écrire, mais l’enregistrement et la publication attendront le retour de la connexion.',
|
||||
@@ -2910,7 +2905,6 @@ export const messages = {
|
||||
'You can write and edit all articles. Publishing is done by an editor.':
|
||||
'Vous pouvez rédiger et modifier tous les articles. La publication est faite par un éditeur.',
|
||||
"Your role can't delete files.": 'Votre rôle ne permet pas de supprimer des fichiers.',
|
||||
'Save draft': 'Enregistrer le brouillon',
|
||||
'The id of the role to give. Absent or 0 means the Writer role. The Owner role is refused.':
|
||||
'L’identifiant du rôle à donner. Absent ou 0, c’est le rôle Rédacteur. Le rôle Propriétaire est refusé.',
|
||||
Access: 'Accès',
|
||||
|
||||
@@ -25,7 +25,6 @@ const KEYS = [
|
||||
"Your role can't delete articles.",
|
||||
'Ask an editor to publish.',
|
||||
'You can write and edit all articles. Publishing is done by an editor.',
|
||||
'Save draft',
|
||||
] as const
|
||||
|
||||
describe('Permission copy', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { messages } from './messages'
|
||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
import { formatSize, pluralArticles } from '@/lib/mediaFormat'
|
||||
import { storyEnvelope, storyListUrl } from '@/views/__tests__/articleEnvelope'
|
||||
import type { ArticleCapability } from '@/lib/articleTypes'
|
||||
import { accessFor, ownerAccess } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async () => {
|
||||
@@ -367,6 +368,7 @@ describe('Hardener localization outcomes', () => {
|
||||
title: 'Data title',
|
||||
body: '',
|
||||
is_online: false,
|
||||
capabilities: ['read', 'edit', 'publish', 'delete'] as ArticleCapability[],
|
||||
created_at: '2025-02-14T12:00:00Z',
|
||||
updated_at: '2025-02-14T12:00:00Z',
|
||||
}
|
||||
@@ -438,6 +440,7 @@ describe('Hardener localization outcomes', () => {
|
||||
body: '<p><img src="/static/uploadedImages/photo.png"></p>',
|
||||
is_online: false,
|
||||
images: [],
|
||||
capabilities: ['read', 'edit', 'publish', 'delete'] as ArticleCapability[],
|
||||
created_at: '2025-02-14T12:00:00Z',
|
||||
updated_at: '2025-02-14T12:00:00Z',
|
||||
}
|
||||
@@ -459,10 +462,15 @@ describe('Hardener localization outcomes', () => {
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find((b) => b.textContent?.trim() === 'Delete…')!
|
||||
.click()
|
||||
} else
|
||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
|
||||
.find((b) => b.textContent?.trim() === 'Delete')!
|
||||
} else {
|
||||
document.body
|
||||
.querySelector<HTMLButtonElement>('[aria-label="More actions"]')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find((b) => b.textContent?.trim() === 'Delete article')!
|
||||
.click()
|
||||
}
|
||||
await flushPromises()
|
||||
const snapshots = []
|
||||
for (const language of ['en', 'fr', 'en'] as const) {
|
||||
@@ -498,6 +506,7 @@ describe('Hardener localization outcomes', () => {
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: false,
|
||||
capabilities: ['read', 'edit', 'publish', 'delete'] as ArticleCapability[],
|
||||
created_at: '2025-02-14T12:00:00Z',
|
||||
updated_at: '2025-02-14T12:00:00Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ArticleCapability } from '@/composables/useArticles'
|
||||
import { planActions, type ActionInput, type SaveMood } from '@/lib/editorActions'
|
||||
|
||||
const EDIT_PUBLISH: ArticleCapability[] = ['read', 'edit', 'publish']
|
||||
const EDIT: ArticleCapability[] = ['read', 'edit']
|
||||
const PUBLISH: ArticleCapability[] = ['read', 'publish']
|
||||
const READ: ArticleCapability[] = ['read']
|
||||
|
||||
function input(overrides: Partial<ActionInput>): ActionInput {
|
||||
return {
|
||||
capabilities: EDIT_PUBLISH,
|
||||
online: false,
|
||||
mood: 'clean',
|
||||
titleInvalid: false,
|
||||
languageDisabled: false,
|
||||
publishing: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function summary(overrides: Partial<ActionInput>) {
|
||||
const plan = planActions(input(overrides))
|
||||
return {
|
||||
primary: plan.primary?.kind ?? null,
|
||||
save: plan.saveButton ? (plan.saveButton.disabled ? 'disabled' : 'enabled') : null,
|
||||
unpublish: plan.unpublish,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Header priority and action matrix', () => {
|
||||
it.each([
|
||||
[
|
||||
'clean draft with publish',
|
||||
{},
|
||||
{ primary: 'publish', save: 'disabled', unpublish: false },
|
||||
],
|
||||
[
|
||||
'clean draft with edit only',
|
||||
{ capabilities: EDIT },
|
||||
{ primary: null, save: 'disabled', unpublish: false },
|
||||
],
|
||||
[
|
||||
'dirty draft with edit only',
|
||||
{ capabilities: EDIT, mood: 'dirty' },
|
||||
{ primary: 'save', save: null, unpublish: false },
|
||||
],
|
||||
[
|
||||
'dirty draft with publish',
|
||||
{ mood: 'dirty' },
|
||||
{ primary: 'saveAndPublish', save: 'enabled', unpublish: false },
|
||||
],
|
||||
['clean online', { online: true }, { primary: null, save: 'disabled', unpublish: true }],
|
||||
[
|
||||
'dirty online',
|
||||
{ online: true, mood: 'dirty' },
|
||||
{ primary: 'save', save: null, unpublish: true },
|
||||
],
|
||||
['saving', { mood: 'saving' }, { primary: 'saving', save: null, unpublish: false }],
|
||||
['saved', { mood: 'saved' }, { primary: 'publish', save: 'disabled', unpublish: false }],
|
||||
[
|
||||
'save failed',
|
||||
{ capabilities: EDIT, mood: 'failed' },
|
||||
{ primary: 'trySaveAgain', save: null, unpublish: false },
|
||||
],
|
||||
[
|
||||
'publish failed after save',
|
||||
{ mood: 'publishFailed' },
|
||||
{ primary: 'retryPublish', save: 'disabled', unpublish: false },
|
||||
],
|
||||
[
|
||||
'publish without edit',
|
||||
{ capabilities: PUBLISH },
|
||||
{ primary: 'publish', save: null, unpublish: false },
|
||||
],
|
||||
['read only', { capabilities: READ }, { primary: null, save: null, unpublish: false }],
|
||||
] as [string, Partial<ActionInput>, ReturnType<typeof summary>][])(
|
||||
'%s',
|
||||
(_name, overrides, expected) => {
|
||||
expect(summary(overrides)).toEqual(expected)
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps Save as the only filled button when saving an online version without publish', () => {
|
||||
const plan = planActions(input({ capabilities: EDIT, online: true, mood: 'dirty' }))
|
||||
|
||||
expect(plan.primary?.kind).toBe('save')
|
||||
expect(plan.unpublish).toBe(false)
|
||||
expect(plan.onlineNotice).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks the primary action while the title is invalid', () => {
|
||||
expect(planActions(input({ mood: 'dirty', titleInvalid: true })).primary?.disabled).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('disables Publish when the language is disabled and falls back to Save when dirty', () => {
|
||||
expect(planActions(input({ languageDisabled: true })).primary).toEqual({
|
||||
kind: 'publish',
|
||||
disabled: true,
|
||||
busy: false,
|
||||
})
|
||||
expect(planActions(input({ languageDisabled: true, mood: 'dirty' })).primary?.kind).toBe(
|
||||
'save',
|
||||
)
|
||||
})
|
||||
|
||||
it('marks the primary busy while publishing', () => {
|
||||
expect(planActions(input({ publishing: true })).primary).toEqual({
|
||||
kind: 'publishing',
|
||||
disabled: true,
|
||||
busy: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('At most one filled button', () => {
|
||||
const moods: SaveMood[] = ['clean', 'dirty', 'saving', 'saved', 'failed', 'publishFailed']
|
||||
const capabilitySets = [READ, EDIT, PUBLISH, EDIT_PUBLISH]
|
||||
|
||||
it.each(
|
||||
capabilitySets.flatMap((capabilities) =>
|
||||
moods.flatMap((mood) =>
|
||||
[true, false].map((online) => ({ capabilities, mood, online })),
|
||||
),
|
||||
),
|
||||
)('$mood online=$online $capabilities', (combination) => {
|
||||
const plan = planActions(input(combination))
|
||||
const filled = [plan.primary].filter(Boolean)
|
||||
|
||||
expect(filled.length).toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission notices', () => {
|
||||
it.each([
|
||||
['readOnly', READ, false],
|
||||
['publishOnly', PUBLISH, false],
|
||||
['editOnly', EDIT, false],
|
||||
[null, EDIT, true],
|
||||
[null, EDIT_PUBLISH, false],
|
||||
] as [string | null, ArticleCapability[], boolean][])(
|
||||
'shows %s for %j online=%s',
|
||||
(notice, capabilities, online) => {
|
||||
expect(planActions(input({ capabilities, online })).notice).toBe(notice)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Every combination keeps the action invariants', () => {
|
||||
const moods: SaveMood[] = ['clean', 'dirty', 'saving', 'saved', 'failed', 'publishFailed']
|
||||
const everyCapability: ArticleCapability[] = [
|
||||
'read',
|
||||
'edit',
|
||||
'publish',
|
||||
'delete',
|
||||
'create_translation',
|
||||
]
|
||||
const capabilitySubsets = everyCapability.reduce<ArticleCapability[][]>(
|
||||
(subsets, capability) => [...subsets, ...subsets.map((subset) => [...subset, capability])],
|
||||
[[]],
|
||||
)
|
||||
const booleans = [true, false]
|
||||
const combinations = capabilitySubsets.flatMap((capabilities) =>
|
||||
moods.flatMap((mood) =>
|
||||
booleans.flatMap((online) =>
|
||||
booleans.flatMap((titleInvalid) =>
|
||||
booleans.flatMap((languageDisabled) =>
|
||||
booleans.map((publishing) => ({
|
||||
capabilities,
|
||||
mood,
|
||||
online,
|
||||
titleInvalid,
|
||||
languageDisabled,
|
||||
publishing,
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const SAVE_KINDS = ['saving', 'publishing', 'save', 'trySaveAgain']
|
||||
const EDIT_KINDS = ['saving', 'save', 'trySaveAgain', 'saveAndPublish']
|
||||
const PUBLISH_KINDS = ['publishing', 'publish', 'retryPublish', 'saveAndPublish']
|
||||
|
||||
it('covers every combination', () => {
|
||||
expect(combinations).toHaveLength(32 * 6 * 16)
|
||||
})
|
||||
|
||||
it('implies the capability behind every primary action', () => {
|
||||
for (const combination of combinations) {
|
||||
const kind = planActions(input(combination)).primary?.kind
|
||||
const label = JSON.stringify(combination)
|
||||
|
||||
expect(
|
||||
!kind || !EDIT_KINDS.includes(kind) || combination.capabilities.includes('edit'),
|
||||
label,
|
||||
).toBe(true)
|
||||
expect(
|
||||
!kind ||
|
||||
!PUBLISH_KINDS.includes(kind) ||
|
||||
combination.capabilities.includes('publish'),
|
||||
label,
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the Save button exactly when the caller can edit and the primary is not save-like', () => {
|
||||
for (const combination of combinations) {
|
||||
const plan = planActions(input(combination))
|
||||
const saveLike = plan.primary !== null && SAVE_KINDS.includes(plan.primary.kind)
|
||||
const expected = combination.capabilities.includes('edit') && !saveLike
|
||||
|
||||
expect(plan.saveButton !== null, JSON.stringify(combination)).toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('offers Unpublish only to a publisher while the version is online and not saving', () => {
|
||||
for (const combination of combinations) {
|
||||
const plan = planActions(input(combination))
|
||||
|
||||
if (plan.unpublish) {
|
||||
expect(
|
||||
combination.capabilities.includes('publish'),
|
||||
JSON.stringify(combination),
|
||||
).toBe(true)
|
||||
expect(combination.online, JSON.stringify(combination)).toBe(true)
|
||||
expect(combination.mood, JSON.stringify(combination)).not.toBe('saving')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { holds } from '@/lib/editorCapabilities'
|
||||
|
||||
describe('Holding a capability', () => {
|
||||
it('is true when the capability is listed', () => {
|
||||
expect(holds(['read', 'edit'], 'edit')).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when it is not listed', () => {
|
||||
expect(holds(['read'], 'edit')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([null, undefined])('is false for %s capabilities', (missing) => {
|
||||
expect(holds(missing, 'read')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { titleLength, titleVerdict } from '@/lib/editorTitle'
|
||||
|
||||
describe('titleVerdict', () => {
|
||||
it.each([
|
||||
['', 'empty'],
|
||||
['ab', 'short'],
|
||||
['abc', 'ok'],
|
||||
['a'.repeat(200), 'ok'],
|
||||
['a'.repeat(201), 'long'],
|
||||
[' ', 'ok'],
|
||||
])('judges %j as %s', (title, verdict) => {
|
||||
expect(titleVerdict(title)).toBe(verdict)
|
||||
})
|
||||
|
||||
it('counts code points, so 200 four-byte emoji are valid', () => {
|
||||
const emoji = '😀'.repeat(200)
|
||||
|
||||
expect(titleLength(emoji)).toBe(200)
|
||||
expect(titleVerdict(emoji)).toBe('ok')
|
||||
expect(titleVerdict(`${emoji}😀`)).toBe('long')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ArticleCapability, CoverageEntry } from '@/composables/useArticles'
|
||||
import { filterRows, versionRows } from '@/lib/editorVersions'
|
||||
|
||||
function entry(
|
||||
id: number,
|
||||
lang: string,
|
||||
capabilities: ArticleCapability[] = ['read', 'edit'],
|
||||
language_enabled = true,
|
||||
): CoverageEntry {
|
||||
return { id, lang, title: lang, is_online: id === 10, language_enabled, capabilities }
|
||||
}
|
||||
|
||||
const translations = [entry(10, 'en'), entry(13, 'ar', ['read']), entry(12, 'fr')]
|
||||
|
||||
function rows(capabilities: ArticleCapability[], enabled = ['en', 'fr', 'de', 'ar']) {
|
||||
return versionRows({ translations, currentId: 12, capabilities, enabledLanguages: enabled })
|
||||
}
|
||||
|
||||
describe('versionRows', () => {
|
||||
it('tolerates an entry without capabilities and tags it read only', () => {
|
||||
const broken = { ...entry(13, 'ar'), capabilities: null } as unknown as CoverageEntry
|
||||
const list = versionRows({
|
||||
translations: [entry(12, 'fr'), broken],
|
||||
currentId: 12,
|
||||
capabilities: ['read'],
|
||||
enabledLanguages: ['fr', 'ar'],
|
||||
})
|
||||
|
||||
expect(list.find((row) => row.lang === 'ar')?.readOnly).toBe(true)
|
||||
})
|
||||
|
||||
it('follows translations[] order and marks the current row', () => {
|
||||
const list = rows(['read', 'edit'])
|
||||
|
||||
expect(list.map((row) => [row.lang, row.current])).toEqual([
|
||||
['en', false],
|
||||
['ar', false],
|
||||
['fr', true],
|
||||
])
|
||||
})
|
||||
|
||||
it('tags a version without edit as read only and keeps editable ones untagged', () => {
|
||||
const list = rows(['read', 'edit'])
|
||||
|
||||
expect(list.find((row) => row.lang === 'ar')?.readOnly).toBe(true)
|
||||
expect(list.find((row) => row.lang === 'en')?.readOnly).toBe(false)
|
||||
})
|
||||
|
||||
it('offers missing enabled languages only with create_translation', () => {
|
||||
expect(rows(['read', 'edit']).some((row) => row.addTranslation)).toBe(false)
|
||||
|
||||
const withAdd = rows(['read', 'edit', 'create_translation'])
|
||||
|
||||
expect(withAdd.filter((row) => row.addTranslation).map((row) => row.lang)).toEqual(['de'])
|
||||
})
|
||||
|
||||
it('keeps a disabled language listed and marked', () => {
|
||||
const list = versionRows({
|
||||
translations: [entry(10, 'en'), entry(12, 'fr', ['read', 'edit'], false)],
|
||||
currentId: 10,
|
||||
capabilities: ['read'],
|
||||
enabledLanguages: ['en'],
|
||||
})
|
||||
|
||||
expect(list.find((row) => row.lang === 'fr')?.languageDisabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterRows', () => {
|
||||
it('narrows by language name, ignoring case', () => {
|
||||
const list = rows(['read'])
|
||||
|
||||
expect(filterRows(list, 'FRE').map((row) => row.lang)).toEqual(['fr'])
|
||||
expect(filterRows(list, '')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { clearStashedArticle, stashArticle, takeStashedArticle } from '@/lib/prefetchedArticle'
|
||||
import type { Article } from '@/lib/articleTypes'
|
||||
|
||||
function article(id: number): Article {
|
||||
return {
|
||||
id,
|
||||
title: 'T',
|
||||
body: '',
|
||||
is_online: false,
|
||||
capabilities: ['read'],
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(clearStashedArticle)
|
||||
|
||||
describe('The prefetched article slot', () => {
|
||||
it('hands the stashed article to the matching id once', () => {
|
||||
stashArticle(article(12))
|
||||
|
||||
expect(takeStashedArticle(12)?.id).toBe(12)
|
||||
expect(takeStashedArticle(12)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null and empties the slot for another id', () => {
|
||||
stashArticle(article(12))
|
||||
|
||||
expect(takeStashedArticle(13)).toBeNull()
|
||||
expect(takeStashedArticle(12)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps only the latest stashed article', () => {
|
||||
stashArticle(article(12))
|
||||
stashArticle(article(13))
|
||||
|
||||
expect(takeStashedArticle(12)).toBeNull()
|
||||
})
|
||||
|
||||
it('forgets the article once cleared', () => {
|
||||
stashArticle(article(12))
|
||||
clearStashedArticle()
|
||||
|
||||
expect(takeStashedArticle(12)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
readStoredDraft,
|
||||
removeStoredDraft,
|
||||
removeStoredDraftsOf,
|
||||
writeStoredDraft,
|
||||
type StoredDraft,
|
||||
} from '@/lib/storedDraft'
|
||||
import { EMPTY_SEO } from '@/lib/articleSeo'
|
||||
|
||||
const MIRRORED_AT = '2026-09-26T11:02:00.000Z'
|
||||
|
||||
const full: StoredDraft = {
|
||||
title: 'Titre',
|
||||
body: '<p>Corps</p>',
|
||||
meta: { site_id: 1, authors: [{ id: 2, name: 'Ines', slug: 'ines' }] },
|
||||
seo: { ...EMPTY_SEO, slug: 'titre', noindex: true, cover_image_id: 4 },
|
||||
mirrored_at: MIRRORED_AT,
|
||||
}
|
||||
|
||||
afterEach(() => sessionStorage.clear())
|
||||
|
||||
describe('Stored drafts', () => {
|
||||
it('round trips a full draft', () => {
|
||||
writeStoredDraft(7, 12, full)
|
||||
|
||||
expect(readStoredDraft(7, 12)).toEqual(full)
|
||||
})
|
||||
|
||||
it('round trips a draft that only holds the mirror time', () => {
|
||||
writeStoredDraft(7, 12, { mirrored_at: MIRRORED_AT })
|
||||
|
||||
expect(readStoredDraft(7, 12)).toEqual({ mirrored_at: MIRRORED_AT })
|
||||
})
|
||||
|
||||
it('reads and writes nothing without a user', () => {
|
||||
writeStoredDraft(null, 12, full)
|
||||
|
||||
expect(readStoredDraft(null, 12)).toBeNull()
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('removes one draft only', () => {
|
||||
writeStoredDraft(7, 12, full)
|
||||
writeStoredDraft(7, 13, full)
|
||||
removeStoredDraft(7, 12)
|
||||
|
||||
expect(readStoredDraft(7, 12)).toBeNull()
|
||||
expect(readStoredDraft(7, 13)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps user 70 when removing the drafts of user 7', () => {
|
||||
writeStoredDraft(7, 12, full)
|
||||
writeStoredDraft(70, 12, full)
|
||||
removeStoredDraftsOf(7)
|
||||
|
||||
expect(readStoredDraft(7, 12)).toBeNull()
|
||||
expect(readStoredDraft(70, 12)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('removes nothing without a user id', () => {
|
||||
writeStoredDraft(7, 12, full)
|
||||
removeStoredDraftsOf(undefined)
|
||||
|
||||
expect(readStoredDraft(7, 12)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Malformed stored drafts read as no draft', () => {
|
||||
const key = 'renewcms:article-draft:7:12'
|
||||
|
||||
it.each([
|
||||
['not JSON', '{oops'],
|
||||
['null', 'null'],
|
||||
['an array', '[]'],
|
||||
['a string', '"draft"'],
|
||||
['no mirror time', JSON.stringify({ title: 'T' })],
|
||||
['an unparseable mirror time', JSON.stringify({ mirrored_at: 'yesterday' })],
|
||||
['a numeric title', JSON.stringify({ title: 3, mirrored_at: MIRRORED_AT })],
|
||||
['a numeric body', JSON.stringify({ body: 3, mirrored_at: MIRRORED_AT })],
|
||||
[
|
||||
'meta without authors',
|
||||
JSON.stringify({ meta: { site_id: 1 }, mirrored_at: MIRRORED_AT }),
|
||||
],
|
||||
[
|
||||
'meta authors that are not an array',
|
||||
JSON.stringify({ meta: { site_id: 1, authors: {} }, mirrored_at: MIRRORED_AT }),
|
||||
],
|
||||
[
|
||||
'an author without a name',
|
||||
JSON.stringify({
|
||||
meta: { site_id: 1, authors: [{ id: 1 }] },
|
||||
mirrored_at: MIRRORED_AT,
|
||||
}),
|
||||
],
|
||||
[
|
||||
'a string site id',
|
||||
JSON.stringify({ meta: { site_id: 'a', authors: [] }, mirrored_at: MIRRORED_AT }),
|
||||
],
|
||||
['seo that is a string', JSON.stringify({ seo: 'x', mirrored_at: MIRRORED_AT })],
|
||||
[
|
||||
'a seo slug that is a number',
|
||||
JSON.stringify({ seo: { slug: 4 }, mirrored_at: MIRRORED_AT }),
|
||||
],
|
||||
[
|
||||
'a seo noindex that is a string',
|
||||
JSON.stringify({ seo: { noindex: 'yes' }, mirrored_at: MIRRORED_AT }),
|
||||
],
|
||||
[
|
||||
'a seo image id that is a string',
|
||||
JSON.stringify({ seo: { cover_image_id: 'a' }, mirrored_at: MIRRORED_AT }),
|
||||
],
|
||||
])('returns null for %s', (_, raw) => {
|
||||
sessionStorage.setItem(key, raw)
|
||||
|
||||
expect(readStoredDraft(7, 12)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,39 @@
|
||||
import type { ArticleSeo } from '@/lib/articleSeo'
|
||||
import type { SuggestionCode } from '@/lib/seoTypes'
|
||||
|
||||
export interface Byline {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
avatar_id?: number | null
|
||||
}
|
||||
|
||||
export type ArticleCapability = 'read' | 'edit' | 'publish' | 'delete' | 'create_translation'
|
||||
|
||||
export interface CoverageEntry {
|
||||
id: number
|
||||
lang: string
|
||||
title: string
|
||||
is_online: boolean
|
||||
language_enabled: boolean
|
||||
capabilities: ArticleCapability[]
|
||||
}
|
||||
|
||||
export interface Article extends Partial<ArticleSeo> {
|
||||
id: number
|
||||
title: string
|
||||
body: string
|
||||
is_online: boolean
|
||||
lang?: string
|
||||
story_id?: number
|
||||
capabilities: ArticleCapability[]
|
||||
translations?: CoverageEntry[]
|
||||
site_id?: number
|
||||
created_by?: number
|
||||
authors?: Byline[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
seo_status?: string
|
||||
seo_dismissed?: SuggestionCode[]
|
||||
seo_suggestions?: number
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ArticleCapability } from '@/lib/articleTypes'
|
||||
import { holds } from '@/lib/editorCapabilities'
|
||||
|
||||
export type SaveMood = 'clean' | 'dirty' | 'saving' | 'saved' | 'failed' | 'publishFailed'
|
||||
|
||||
export type PrimaryKind =
|
||||
| 'saveAndPublish'
|
||||
| 'save'
|
||||
| 'trySaveAgain'
|
||||
| 'publish'
|
||||
| 'retryPublish'
|
||||
| 'saving'
|
||||
| 'publishing'
|
||||
|
||||
export interface PrimaryAction {
|
||||
kind: PrimaryKind
|
||||
disabled: boolean
|
||||
busy: boolean
|
||||
}
|
||||
|
||||
export type Notice = 'readOnly' | 'publishOnly' | 'editOnly'
|
||||
|
||||
export interface ActionInput {
|
||||
capabilities: ArticleCapability[]
|
||||
online: boolean
|
||||
mood: SaveMood
|
||||
titleInvalid: boolean
|
||||
languageDisabled: boolean
|
||||
publishing: boolean
|
||||
}
|
||||
|
||||
export interface ActionPlan {
|
||||
primary: PrimaryAction | null
|
||||
saveButton: { disabled: boolean } | null
|
||||
unpublish: boolean
|
||||
notice: Notice | null
|
||||
onlineNotice: boolean
|
||||
}
|
||||
|
||||
function action(kind: PrimaryKind, disabled = false, busy = false): PrimaryAction {
|
||||
return { kind, disabled, busy }
|
||||
}
|
||||
|
||||
function primaryOf(input: ActionInput): PrimaryAction | null {
|
||||
const canEdit = holds(input.capabilities, 'edit')
|
||||
const canPublish = holds(input.capabilities, 'publish')
|
||||
const unsaved = input.mood === 'dirty' || input.mood === 'failed'
|
||||
const mayPublishNow = canPublish && !input.languageDisabled
|
||||
if (input.mood === 'saving' && canEdit) {
|
||||
return action('saving', true, true)
|
||||
}
|
||||
if (input.publishing && !input.online && canPublish) {
|
||||
return action('publishing', true, true)
|
||||
}
|
||||
if (input.mood === 'publishFailed' && canPublish) {
|
||||
return input.languageDisabled ? action('publish', true) : action('retryPublish')
|
||||
}
|
||||
if (canEdit && unsaved && mayPublishNow && !input.online && input.mood !== 'failed') {
|
||||
return action('saveAndPublish', input.titleInvalid)
|
||||
}
|
||||
if (canEdit && unsaved) {
|
||||
return action(input.mood === 'failed' ? 'trySaveAgain' : 'save', input.titleInvalid)
|
||||
}
|
||||
if (canPublish && !input.online) {
|
||||
return action('publish', input.languageDisabled)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isSavePrimary(primary: PrimaryAction | null) {
|
||||
return (
|
||||
primary !== null &&
|
||||
(primary.kind === 'saving' ||
|
||||
primary.kind === 'publishing' ||
|
||||
primary.kind === 'save' ||
|
||||
primary.kind === 'trySaveAgain')
|
||||
)
|
||||
}
|
||||
|
||||
function noticeOf(capabilities: ArticleCapability[], online: boolean): Notice | null {
|
||||
const canEdit = holds(capabilities, 'edit')
|
||||
const canPublish = holds(capabilities, 'publish')
|
||||
if (!canEdit) {
|
||||
return canPublish ? 'publishOnly' : 'readOnly'
|
||||
}
|
||||
return canPublish || online ? null : 'editOnly'
|
||||
}
|
||||
|
||||
export function planActions(input: ActionInput): ActionPlan {
|
||||
const canEdit = holds(input.capabilities, 'edit')
|
||||
const canPublish = holds(input.capabilities, 'publish')
|
||||
const unsaved = input.mood === 'dirty' || input.mood === 'failed'
|
||||
const primary = primaryOf(input)
|
||||
return {
|
||||
primary,
|
||||
saveButton: canEdit && !isSavePrimary(primary) ? { disabled: !unsaved } : null,
|
||||
unpublish: canPublish && input.online && input.mood !== 'saving',
|
||||
notice: noticeOf(input.capabilities, input.online),
|
||||
onlineNotice: canEdit && input.online,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ArticleCapability } from '@/lib/articleTypes'
|
||||
|
||||
export function holds(
|
||||
capabilities: ArticleCapability[] | null | undefined,
|
||||
needed: ArticleCapability,
|
||||
) {
|
||||
return capabilities?.includes(needed) ?? false
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { PrimaryKind } from '@/lib/editorActions'
|
||||
|
||||
export function primaryLabel(kind: PrimaryKind, language: string | null) {
|
||||
const labels: Record<PrimaryKind, () => string> = {
|
||||
saveAndPublish: () =>
|
||||
language ? t('Save and publish {0}', [language]) : t('Save and publish'),
|
||||
save: () => t('Save'),
|
||||
trySaveAgain: () => t('Try saving again'),
|
||||
publish: () => (language ? t('Publish {0}', [language]) : t('Publish')),
|
||||
retryPublish: () => t('Retry publish'),
|
||||
saving: () => t('Saving…'),
|
||||
publishing: () => t('Publishing…'),
|
||||
}
|
||||
return labels[kind]()
|
||||
}
|
||||
|
||||
export function unpublishLabel(language: string | null) {
|
||||
return language ? t('Unpublish {0}', [language]) : t('Unpublish')
|
||||
}
|
||||
|
||||
export function titleLabel(language: string | null) {
|
||||
return language ? t('Title ({0})', [language]) : t('Title')
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { Notice } from '@/lib/editorActions'
|
||||
|
||||
export interface EditorNotice {
|
||||
key: string
|
||||
tone: 'info' | 'error' | 'warning' | 'neutral'
|
||||
icon: string
|
||||
title: string
|
||||
description?: string
|
||||
action?: 'retryPublish'
|
||||
}
|
||||
|
||||
export interface NoticeInput {
|
||||
language: string | null
|
||||
restoredAt: Date | null
|
||||
saveFailed: boolean
|
||||
publishFailed: boolean
|
||||
languageDisabled: boolean
|
||||
accessNotice: Notice | null
|
||||
onlineNotice: boolean
|
||||
}
|
||||
|
||||
function clockOf(date: Date) {
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function restoredNotice(input: NoticeInput, restoredAt: Date): EditorNotice {
|
||||
return {
|
||||
key: 'restored',
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: t('Restored unsaved changes from {0}', [clockOf(restoredAt)]),
|
||||
description: input.language
|
||||
? t('You logged in again. Your {0} title and body were kept in this browser.', [
|
||||
input.language,
|
||||
])
|
||||
: t('You logged in again. Your title and body were kept in this browser.'),
|
||||
}
|
||||
}
|
||||
|
||||
function saveFailedNotice(input: NoticeInput): EditorNotice {
|
||||
return {
|
||||
key: 'saveFailed',
|
||||
tone: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: input.language ? t("Couldn't save {0}", [input.language]) : t("Couldn't save"),
|
||||
description: t('Your changes are kept here. Nothing was published. Try saving again.'),
|
||||
}
|
||||
}
|
||||
|
||||
function publishFailedNotice(): EditorNotice {
|
||||
return {
|
||||
key: 'publishFailed',
|
||||
tone: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: t('Saved as a draft. Publishing failed. Try publishing again.'),
|
||||
description: t('Your edits are saved. Only the publishing step failed.'),
|
||||
action: 'retryPublish',
|
||||
}
|
||||
}
|
||||
|
||||
function languageDisabledNotice(language: string): EditorNotice {
|
||||
return {
|
||||
key: 'languageDisabled',
|
||||
tone: 'warning',
|
||||
icon: 'i-lucide-triangle-alert',
|
||||
title: t('{0} is disabled on this site. Re-enable it to publish.', [language]),
|
||||
description: t('You can still edit and save this version. Ask an owner to re-enable {0}.', [
|
||||
language,
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
function accessNoticeOf(notice: Notice): EditorNotice {
|
||||
const notices: Record<Notice, () => EditorNotice> = {
|
||||
readOnly: () => ({
|
||||
key: 'readOnly',
|
||||
tone: 'neutral',
|
||||
icon: 'i-lucide-lock',
|
||||
title: t("You can read this version. You can't edit it."),
|
||||
description: t('The source reference and the versions you can read stay available.'),
|
||||
}),
|
||||
publishOnly: () => ({
|
||||
key: 'publishOnly',
|
||||
tone: 'neutral',
|
||||
icon: 'i-lucide-lock',
|
||||
title: t('You can publish this version but not edit it'),
|
||||
description: t('Fields are read-only. Publish stays available.'),
|
||||
}),
|
||||
editOnly: () => ({
|
||||
key: 'editOnly',
|
||||
tone: 'neutral',
|
||||
icon: 'i-lucide-info',
|
||||
title: t('Publishing needs the publish permission'),
|
||||
description: t('Saving keeps it as a draft.'),
|
||||
}),
|
||||
}
|
||||
return notices[notice]()
|
||||
}
|
||||
|
||||
function onlineNotice(): EditorNotice {
|
||||
return {
|
||||
key: 'online',
|
||||
tone: 'neutral',
|
||||
icon: 'i-lucide-info',
|
||||
title: t('This version is online. Saving updates the live page right away.'),
|
||||
}
|
||||
}
|
||||
|
||||
export function editorNotices(input: NoticeInput): EditorNotice[] {
|
||||
const notices: EditorNotice[] = []
|
||||
if (input.restoredAt) {
|
||||
notices.push(restoredNotice(input, input.restoredAt))
|
||||
}
|
||||
if (input.saveFailed) {
|
||||
notices.push(saveFailedNotice(input))
|
||||
}
|
||||
if (input.publishFailed) {
|
||||
notices.push(publishFailedNotice())
|
||||
}
|
||||
if (input.languageDisabled && input.language) {
|
||||
notices.push(languageDisabledNotice(input.language))
|
||||
}
|
||||
if (input.accessNotice) {
|
||||
notices.push(accessNoticeOf(input.accessNotice))
|
||||
}
|
||||
if (input.onlineNotice) {
|
||||
notices.push(onlineNotice())
|
||||
}
|
||||
return notices
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { ArticleCapability, CoverageEntry } from '@/lib/articleTypes'
|
||||
import { holds } from '@/lib/editorCapabilities'
|
||||
import { unpublishLabel } from '@/lib/editorLabels'
|
||||
|
||||
export interface OverflowItem {
|
||||
key: 'unpublish' | 'editSlug' | 'move' | 'deleteVersion' | 'deleteArticle'
|
||||
label: string
|
||||
icon: string
|
||||
danger: boolean
|
||||
disabled: boolean
|
||||
reason: string
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export interface OverflowContext {
|
||||
capabilities: ArticleCapability[]
|
||||
siblings: CoverageEntry[]
|
||||
language: string | null
|
||||
unpublishInMenu: boolean
|
||||
handlers: Partial<Record<OverflowItem['key'], () => void>>
|
||||
}
|
||||
|
||||
function ignore() {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function unavailable(key: OverflowItem['key'], context: OverflowContext): Partial<OverflowItem> {
|
||||
return context.handlers[key] ? {} : { disabled: true, reason: t('Not available yet.') }
|
||||
}
|
||||
|
||||
function item(
|
||||
key: OverflowItem['key'],
|
||||
label: string,
|
||||
icon: string,
|
||||
context: OverflowContext,
|
||||
extra: Partial<OverflowItem> = {},
|
||||
): OverflowItem {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
icon,
|
||||
danger: false,
|
||||
disabled: false,
|
||||
reason: '',
|
||||
onSelect: context.handlers[key] ?? ignore,
|
||||
...unavailable(key, context),
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function deleteVersionLabel(language: string | null) {
|
||||
return language ? t('Delete {0} version', [language]) : t('Delete article')
|
||||
}
|
||||
|
||||
function blocksWholeArticle(context: OverflowContext) {
|
||||
return context.siblings.some((sibling) => !holds(sibling.capabilities, 'delete'))
|
||||
}
|
||||
|
||||
function blockedExtra(blocked: boolean): Partial<OverflowItem> {
|
||||
return blocked ? { disabled: true, reason: t("You can't delete this article.") } : {}
|
||||
}
|
||||
|
||||
function editItems(context: OverflowContext): OverflowItem[] {
|
||||
const blocked = blocksWholeArticle(context)
|
||||
return [
|
||||
item('editSlug', t('Edit slug'), 'i-lucide-link', context),
|
||||
item(
|
||||
'move',
|
||||
t('Move article'),
|
||||
'i-lucide-arrow-right-left',
|
||||
context,
|
||||
blockedExtra(blocked),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function deleteItems(context: OverflowContext): OverflowItem[] {
|
||||
const versionItem = item(
|
||||
'deleteVersion',
|
||||
deleteVersionLabel(context.language),
|
||||
'i-lucide-trash-2',
|
||||
context,
|
||||
{ danger: true },
|
||||
)
|
||||
if (!context.language) {
|
||||
return [versionItem]
|
||||
}
|
||||
const blocked = blocksWholeArticle(context)
|
||||
return [
|
||||
versionItem,
|
||||
item('deleteArticle', t('Delete article'), 'i-lucide-trash-2', context, {
|
||||
danger: !blocked,
|
||||
...blockedExtra(blocked),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
export function overflowItems(context: OverflowContext): OverflowItem[] {
|
||||
const items: OverflowItem[] = []
|
||||
if (context.unpublishInMenu) {
|
||||
items.push(item('unpublish', unpublishLabel(context.language), 'i-lucide-eye-off', context))
|
||||
}
|
||||
if (holds(context.capabilities, 'edit')) {
|
||||
items.push(...editItems(context))
|
||||
}
|
||||
if (holds(context.capabilities, 'delete')) {
|
||||
items.push(...deleteItems(context))
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const TITLE_MIN = 3
|
||||
export const TITLE_MAX = 200
|
||||
export const TITLE_SERVER_MESSAGE = 'Article title must be between 3 and 200 characters.'
|
||||
|
||||
export type TitleVerdict = 'ok' | 'empty' | 'short' | 'long'
|
||||
|
||||
export function titleLength(title: string) {
|
||||
return Array.from(title).length
|
||||
}
|
||||
|
||||
export function titleVerdict(title: string): TitleVerdict {
|
||||
const length = titleLength(title)
|
||||
if (length === 0) {
|
||||
return 'empty'
|
||||
}
|
||||
if (length < TITLE_MIN) {
|
||||
return 'short'
|
||||
}
|
||||
return length > TITLE_MAX ? 'long' : 'ok'
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { locale } from '@/i18n'
|
||||
import type { ArticleCapability, CoverageEntry } from '@/lib/articleTypes'
|
||||
import { holds } from '@/lib/editorCapabilities'
|
||||
import { languageName } from '@/lib/siteLanguages'
|
||||
|
||||
export const MENU_FILTER_THRESHOLD = 8
|
||||
|
||||
export interface VersionRow {
|
||||
key: string
|
||||
lang: string
|
||||
name: string
|
||||
code: string
|
||||
articleId: number | null
|
||||
title: string
|
||||
current: boolean
|
||||
online: boolean
|
||||
readOnly: boolean
|
||||
languageDisabled: boolean
|
||||
addTranslation: boolean
|
||||
}
|
||||
|
||||
export interface VersionContext {
|
||||
translations: CoverageEntry[]
|
||||
currentId: number
|
||||
capabilities: ArticleCapability[]
|
||||
enabledLanguages: string[]
|
||||
}
|
||||
|
||||
function versionRow(entry: CoverageEntry, currentId: number): VersionRow {
|
||||
const current = entry.id === currentId
|
||||
return {
|
||||
key: `version-${entry.id}`,
|
||||
lang: entry.lang,
|
||||
name: languageName(entry.lang, locale.value),
|
||||
code: entry.lang.toUpperCase(),
|
||||
articleId: entry.id,
|
||||
title: entry.title,
|
||||
current,
|
||||
online: entry.is_online,
|
||||
readOnly:
|
||||
!current && !holds(entry.capabilities, 'edit') && !holds(entry.capabilities, 'publish'),
|
||||
languageDisabled: !entry.language_enabled,
|
||||
addTranslation: false,
|
||||
}
|
||||
}
|
||||
|
||||
function missingRow(lang: string): VersionRow {
|
||||
return {
|
||||
key: `missing-${lang}`,
|
||||
lang,
|
||||
name: languageName(lang, locale.value),
|
||||
code: lang.toUpperCase(),
|
||||
articleId: null,
|
||||
title: '',
|
||||
current: false,
|
||||
online: false,
|
||||
readOnly: false,
|
||||
languageDisabled: false,
|
||||
addTranslation: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function versionRows(context: VersionContext): VersionRow[] {
|
||||
const rows = context.translations.map((entry) => versionRow(entry, context.currentId))
|
||||
if (!holds(context.capabilities, 'create_translation')) {
|
||||
return rows
|
||||
}
|
||||
const present = new Set(context.translations.map((entry) => entry.lang))
|
||||
const missing = context.enabledLanguages.filter((lang) => !present.has(lang))
|
||||
return [...rows, ...missing.map(missingRow)]
|
||||
}
|
||||
|
||||
export function filterRows(rows: VersionRow[], query: string) {
|
||||
const needle = query.trim().toLocaleLowerCase()
|
||||
if (!needle) {
|
||||
return rows
|
||||
}
|
||||
return rows.filter((row) => row.name.toLocaleLowerCase().includes(needle))
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export function joinNames(names: string[]) {
|
||||
const last = names[names.length - 1]
|
||||
if (last === undefined || names.length === 1) {
|
||||
return last ?? ''
|
||||
}
|
||||
return `${names.slice(0, -1).join(', ')}${t(' and ')}${last}`
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Article } from '@/lib/articleTypes'
|
||||
|
||||
let stashed: Article | null = null
|
||||
|
||||
export function stashArticle(article: Article) {
|
||||
stashed = article
|
||||
}
|
||||
|
||||
export function takeStashedArticle(id: number): Article | null {
|
||||
const found = stashed?.id === id ? stashed : null
|
||||
stashed = null
|
||||
return found
|
||||
}
|
||||
|
||||
export function clearStashedArticle() {
|
||||
stashed = null
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { Byline } from '@/lib/articleTypes'
|
||||
import { SEO_KEYS, type ArticleSeo, type SeoKey } from '@/lib/articleSeo'
|
||||
|
||||
export interface StoredDraft {
|
||||
title?: string
|
||||
body?: string
|
||||
meta?: { site_id: number | null; authors: Byline[] }
|
||||
seo?: ArticleSeo
|
||||
mirrored_at: string
|
||||
}
|
||||
|
||||
const DRAFT_PREFIX = 'renewcms:article-draft:'
|
||||
|
||||
const NULLABLE_NUMBER_KEYS: SeoKey[] = ['cover_image_id', 'social_image_id']
|
||||
const BOOLEAN_KEYS: SeoKey[] = ['noindex']
|
||||
|
||||
type UnknownRecord = Record<string, unknown>
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isOptionalString(value: unknown) {
|
||||
return value === undefined || typeof value === 'string'
|
||||
}
|
||||
|
||||
function isNullableNumber(value: unknown) {
|
||||
return value === null || typeof value === 'number'
|
||||
}
|
||||
|
||||
function isByline(value: unknown) {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.id === 'number' &&
|
||||
typeof value.name === 'string' &&
|
||||
typeof value.slug === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isMeta(value: unknown) {
|
||||
return (
|
||||
value === undefined ||
|
||||
(isRecord(value) &&
|
||||
isNullableNumber(value.site_id) &&
|
||||
Array.isArray(value.authors) &&
|
||||
value.authors.every(isByline))
|
||||
)
|
||||
}
|
||||
|
||||
function isSeoValue(key: SeoKey, value: unknown) {
|
||||
if (NULLABLE_NUMBER_KEYS.includes(key)) {
|
||||
return isNullableNumber(value)
|
||||
}
|
||||
return BOOLEAN_KEYS.includes(key) ? typeof value === 'boolean' : typeof value === 'string'
|
||||
}
|
||||
|
||||
function isSeo(value: unknown) {
|
||||
return (
|
||||
value === undefined ||
|
||||
(isRecord(value) &&
|
||||
SEO_KEYS.every((key) => value[key] === undefined || isSeoValue(key, value[key])))
|
||||
)
|
||||
}
|
||||
|
||||
function isMirrorTime(value: unknown) {
|
||||
return typeof value === 'string' && !Number.isNaN(new Date(value).getTime())
|
||||
}
|
||||
|
||||
function isStoredDraft(value: unknown): value is StoredDraft {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
isOptionalString(value.title) &&
|
||||
isOptionalString(value.body) &&
|
||||
isMeta(value.meta) &&
|
||||
isSeo(value.seo) &&
|
||||
isMirrorTime(value.mirrored_at)
|
||||
)
|
||||
}
|
||||
|
||||
function keyOf(userId: number, articleId: number) {
|
||||
return `${DRAFT_PREFIX}${userId}:${articleId}`
|
||||
}
|
||||
|
||||
export function readStoredDraft(userId: number | null, articleId: number): StoredDraft | null {
|
||||
if (userId === null) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const raw = sessionStorage.getItem(keyOf(userId, articleId))
|
||||
const parsed: unknown = raw === null ? null : JSON.parse(raw)
|
||||
return isStoredDraft(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredDraft(userId: number | null, articleId: number, draft: StoredDraft) {
|
||||
if (userId === null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(keyOf(userId, articleId), JSON.stringify(draft))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function removeStoredDraft(userId: number | null, articleId: number) {
|
||||
if (userId === null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
sessionStorage.removeItem(keyOf(userId, articleId))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function removeStoredDraftsOf(userId: number | undefined) {
|
||||
if (userId === undefined) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const prefix = `${DRAFT_PREFIX}${userId}:`
|
||||
const owned = Object.keys(sessionStorage).filter((key) => key.startsWith(prefix))
|
||||
owned.forEach((key) => sessionStorage.removeItem(key))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import { locale, t } from '@/i18n'
|
||||
import type {
|
||||
ArticleCapability,
|
||||
CoverageEntry,
|
||||
StoryCounts,
|
||||
StoryItem,
|
||||
} from '@/composables/useArticles'
|
||||
import type { StoryCounts, StoryItem } from '@/composables/useArticles'
|
||||
import type { ArticleCapability, CoverageEntry } from '@/lib/articleTypes'
|
||||
import type { ArticleListQuery, ArticleStatus } from '@/lib/articleListQuery'
|
||||
import { authorsSummary } from '@/lib/articleMeta'
|
||||
import { formatAbsoluteDate, formatRelativeDate } from '@/lib/relativeDate'
|
||||
|
||||
+278
-82
@@ -13,9 +13,16 @@ import MetaSection from '@/components/MetaSection.vue'
|
||||
import SearchSharingSection from '@/components/SearchSharingSection.vue'
|
||||
import SeoPanel from '@/components/SeoPanel.vue'
|
||||
import ApiPreviewDialog from '@/components/ApiPreviewDialog.vue'
|
||||
import EditorActions from '@/components/EditorActions.vue'
|
||||
import EditorHeader from '@/components/EditorHeader.vue'
|
||||
import EditorLanguageMenu from '@/components/EditorLanguageMenu.vue'
|
||||
import EditorMobileBar from '@/components/EditorMobileBar.vue'
|
||||
import EditorNotices from '@/components/EditorNotices.vue'
|
||||
import EditorSkeleton from '@/components/EditorSkeleton.vue'
|
||||
import EditorTitleField from '@/components/EditorTitleField.vue'
|
||||
import EditorVersionGone from '@/components/EditorVersionGone.vue'
|
||||
import LocaleBadge from '@/components/localization/LocaleBadge.vue'
|
||||
import SaveStatus from '@/components/SaveStatus.vue'
|
||||
import { neutralizeUnsafeLinks } from '@/lib/articleLinkUrl'
|
||||
import EditorStatusBar from '@/components/EditorStatusBar.vue'
|
||||
import ImageUploader from '@/components/ImageUploader.vue'
|
||||
@@ -24,13 +31,14 @@ import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { useArticleDraft, type SaveOutcome } from '@/composables/useArticleDraft'
|
||||
import { useArticleLifecycle } from '@/composables/useArticleLifecycle'
|
||||
import { useContentAccess } from '@/composables/useContentAccess'
|
||||
import { useEditorPublishing } from '@/composables/useEditorPublishing'
|
||||
import { useEditorVersions } from '@/composables/useEditorVersions'
|
||||
import { useVersionSwitch } from '@/composables/useVersionSwitch'
|
||||
import { useEditorFocus } from '@/composables/useEditorFocus'
|
||||
import { useEditorImages } from '@/composables/useEditorImages'
|
||||
import { useEditorSeoPanel } from '@/composables/useEditorSeoPanel'
|
||||
import { useSeoDismissals } from '@/composables/useSeoDismissals'
|
||||
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
||||
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
|
||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||
import { useSites, type Site } from '@/composables/useSites'
|
||||
import { toBylines } from '@/lib/articleMeta'
|
||||
@@ -40,6 +48,15 @@ import { SEO_LIMITS, seoOf } from '@/lib/articleSeo'
|
||||
import { hostOf, slugify } from '@/lib/directory'
|
||||
import { bodyCounts } from '@/lib/bodyCounts'
|
||||
import { useCanManageVariants } from '@/composables/useCanManageVariants'
|
||||
import { planActions, type SaveMood } from '@/lib/editorActions'
|
||||
import { holds } from '@/lib/editorCapabilities'
|
||||
import { titleLabel } from '@/lib/editorLabels'
|
||||
import { editorNotices } from '@/lib/editorNotices'
|
||||
import { overflowItems } from '@/lib/editorOverflow'
|
||||
import { titleVerdict } from '@/lib/editorTitle'
|
||||
import { takeStashedArticle } from '@/lib/prefetchedArticle'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useIsDesktop } from '@/composables/useIsDesktop'
|
||||
import { bodyHasVariantImage } from '@/lib/variants/blockVariant'
|
||||
import { useVariantCatalogStore } from '@/stores/variantCatalog'
|
||||
|
||||
@@ -68,9 +85,10 @@ const {
|
||||
} = useEditorImages(() => draft.body.value)
|
||||
|
||||
const articleId = Number(route.params.id)
|
||||
const draft = useArticleDraft(articleId)
|
||||
const draft = useArticleDraft(articleId, useSessionStore().session?.id ?? null)
|
||||
const variantCatalog = useVariantCatalogStore()
|
||||
const canManageVariants = useCanManageVariants()
|
||||
const wideScreen = useIsDesktop('80rem')
|
||||
|
||||
usePageTitleOverride(() => article.value?.title)
|
||||
|
||||
@@ -85,9 +103,13 @@ const sites = ref<Site[]>([])
|
||||
const remembered = ref<boolean | null>(readMetaSectionOpen())
|
||||
const seoRemembered = ref<boolean | null>(readSeoSectionOpen())
|
||||
const apiPreviewOpen = ref(false)
|
||||
const leaving = ref(false)
|
||||
const titleField = ref<{ focus: () => void } | null>(null)
|
||||
const titleSaveFailed = ref(false)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function markNotFound() {
|
||||
draft.discardStored()
|
||||
loadState.value = 'notFound'
|
||||
}
|
||||
|
||||
@@ -168,9 +190,16 @@ const address = computed(
|
||||
|
||||
function editBody(newBody: string) {
|
||||
draft.edit(newBody)
|
||||
publishing.clearPublishFailure()
|
||||
check.schedule()
|
||||
}
|
||||
|
||||
function editTitle(next: string) {
|
||||
titleSaveFailed.value = false
|
||||
draft.editTitle(next)
|
||||
publishing.clearPublishFailure()
|
||||
}
|
||||
|
||||
async function loadArticle() {
|
||||
loadState.value = 'loading'
|
||||
showSkeleton.value = false
|
||||
@@ -180,13 +209,17 @@ async function loadArticle() {
|
||||
}, SKELETON_DELAY_MS)
|
||||
|
||||
try {
|
||||
const stashed = takeStashedArticle(articleId)
|
||||
const [result, siteList] = await Promise.all([
|
||||
request<Article>(`/v1/article/${articleId}?format=html`),
|
||||
stashed ?? request<Article>(`/v1/article/${articleId}?format=html`),
|
||||
fetchSites(),
|
||||
])
|
||||
article.value = result
|
||||
dismissedCodes.value = result.seo_dismissed ?? []
|
||||
sites.value = siteList
|
||||
if (!holds(result.capabilities, 'edit')) {
|
||||
draft.discardStored()
|
||||
}
|
||||
draft.load(
|
||||
result.body,
|
||||
new Date(result.updated_at),
|
||||
@@ -195,7 +228,9 @@ async function loadArticle() {
|
||||
authors: toBylines(result.authors ?? []),
|
||||
},
|
||||
seoOf(result),
|
||||
result.title,
|
||||
)
|
||||
await versions.loadLanguages()
|
||||
loadState.value = 'loaded'
|
||||
check.runNow()
|
||||
if (canManageVariants.value && bodyHasVariantImage(result.body)) {
|
||||
@@ -215,43 +250,106 @@ async function loadArticle() {
|
||||
|
||||
const counts = computed(() => bodyCounts(draft.body.value))
|
||||
const previewBody = computed(() => neutralizeUnsafeLinks(draft.body.value))
|
||||
const countText = computed(() =>
|
||||
t('{0} words · {1} characters', [
|
||||
counts.value.words.toLocaleString('en-US'),
|
||||
counts.value.characters.toLocaleString('en-US'),
|
||||
]),
|
||||
|
||||
const capabilities = computed(() => article.value?.capabilities ?? [])
|
||||
const canEdit = computed(() => holds(capabilities.value, 'edit'))
|
||||
const versions = useEditorVersions(article, sites, capabilities)
|
||||
|
||||
const offline = computed(() => draft.state.value === 'offline')
|
||||
const unsavedChanges = computed(
|
||||
() => draft.businessState.value === 'dirty' || draft.businessState.value === 'error',
|
||||
)
|
||||
const overflowItems = computed(() => [
|
||||
[
|
||||
{
|
||||
icon: 'i-lucide-trash-2',
|
||||
color: 'error' as const,
|
||||
label: t('Delete article'),
|
||||
onSelect: () => {
|
||||
handleDelete()
|
||||
},
|
||||
const verdict = computed(() => titleVerdict(draft.title.value))
|
||||
const titleInvalid = computed(() => verdict.value !== 'ok' || draft.titleServerError.value !== '')
|
||||
|
||||
const mood = computed<SaveMood>(() => {
|
||||
if (publishing.publishFailed.value && !unsavedChanges.value) {
|
||||
return 'publishFailed'
|
||||
}
|
||||
const moods: Record<string, SaveMood> = {
|
||||
clean: 'clean',
|
||||
dirty: 'dirty',
|
||||
saving: 'saving',
|
||||
saved: 'saved',
|
||||
error: 'failed',
|
||||
}
|
||||
return moods[draft.businessState.value] ?? 'clean'
|
||||
})
|
||||
|
||||
const plan = computed(() =>
|
||||
planActions({
|
||||
capabilities: capabilities.value,
|
||||
online: article.value?.is_online ?? false,
|
||||
mood: mood.value,
|
||||
titleInvalid: titleInvalid.value,
|
||||
languageDisabled: versions.languageDisabled.value || publishing.languageBlocked.value,
|
||||
publishing: publishing.publishing.value || publishPending.value !== undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
const draftHint = computed(() =>
|
||||
plan.value.notice === 'editOnly' && mood.value !== 'clean' && mood.value !== 'saved'
|
||||
? t('Saving keeps it as a draft.')
|
||||
: '',
|
||||
)
|
||||
|
||||
const notices = computed(() =>
|
||||
editorNotices({
|
||||
language: versions.sentenceName.value,
|
||||
restoredAt: draft.restoredAt.value,
|
||||
saveFailed: mood.value === 'failed' && !titleSaveFailed.value,
|
||||
publishFailed: mood.value === 'publishFailed',
|
||||
languageDisabled: versions.languageDisabled.value || publishing.languageBlocked.value,
|
||||
accessNotice: draftHint.value ? null : plan.value.notice,
|
||||
onlineNotice: plan.value.onlineNotice,
|
||||
}),
|
||||
)
|
||||
|
||||
const overflow = computed(() =>
|
||||
overflowItems({
|
||||
capabilities: capabilities.value,
|
||||
siblings: versions.siblings.value,
|
||||
language: versions.sentenceName.value,
|
||||
unpublishInMenu: plan.value.unpublish && !wideScreen.value,
|
||||
handlers: {
|
||||
unpublish: handleUnpublish,
|
||||
deleteVersion: handleDelete,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const contentAccess = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
|
||||
const articleAccess = computed(() => contentAccess.rowAccess(article.value?.created_by))
|
||||
const canPublish = computed(() => contentAccess.canPublish(article.value?.created_by))
|
||||
const saveEnabled = computed(
|
||||
() =>
|
||||
draft.state.value === 'dirty' &&
|
||||
loadState.value === 'loaded' &&
|
||||
articleAccess.value.canEdit,
|
||||
}),
|
||||
)
|
||||
|
||||
const publishDisabled = computed(() => draft.state.value === 'offline')
|
||||
function announceSaved() {
|
||||
const names = versions.siblingNames.value
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('{0} saved', [versions.sentenceName.value ?? ''])
|
||||
},
|
||||
get description() {
|
||||
if (names.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return names.length === 1
|
||||
? t("{0} wasn't changed.", [names[0]])
|
||||
: t("Other versions weren't changed.")
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async function runSave(): Promise<SaveOutcome> {
|
||||
async function runSave(options: { announce: boolean } = { announce: true }): Promise<SaveOutcome> {
|
||||
const hadTitleEdit = draft.title.value !== article.value?.title
|
||||
const outcome = await draft.save()
|
||||
if (outcome === 'saved') {
|
||||
titleSaveFailed.value = false
|
||||
syncSavedTitle()
|
||||
check.invalidate()
|
||||
check.runFresh()
|
||||
if (versions.multilingual.value && options.announce) {
|
||||
announceSaved()
|
||||
}
|
||||
}
|
||||
titleSaveFailed.value = outcome === 'error' && hadTitleEdit
|
||||
revealSaveFailure(outcome)
|
||||
if (outcome === 'site-gone') {
|
||||
directoryToasts.siteGone()
|
||||
@@ -272,24 +370,40 @@ async function runSave(): Promise<SaveOutcome> {
|
||||
return outcome
|
||||
}
|
||||
|
||||
function syncSavedTitle() {
|
||||
const current = article.value
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
article.value = { ...current, title: draft.title.value }
|
||||
versions.markTitle(current.id, draft.title.value)
|
||||
}
|
||||
|
||||
async function performSave() {
|
||||
if (!saveEnabled.value || !article.value) {
|
||||
if (!article.value || !canEdit.value || !unsavedChanges.value) {
|
||||
return
|
||||
}
|
||||
if (titleInvalid.value || offline.value) {
|
||||
return
|
||||
}
|
||||
await runSave()
|
||||
}
|
||||
|
||||
function retrySave() {
|
||||
if (!article.value) {
|
||||
return
|
||||
}
|
||||
runSave()
|
||||
async function retryTitle() {
|
||||
await performSave()
|
||||
titleField.value?.focus()
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/article')
|
||||
}
|
||||
|
||||
function updateArticle(updated: Article) {
|
||||
const current = article.value
|
||||
article.value = { ...current, ...updated, translations: current?.translations }
|
||||
versions.markOnline(updated.id, updated.is_online)
|
||||
}
|
||||
|
||||
function setOnline(makeOnline: boolean) {
|
||||
const current = article.value
|
||||
if (!current) {
|
||||
@@ -312,12 +426,55 @@ function setOnline(makeOnline: boolean) {
|
||||
})
|
||||
}
|
||||
|
||||
const publishing = useEditorPublishing({
|
||||
articleId,
|
||||
language: () => versions.sentenceName.value ?? '',
|
||||
siblingNames: () => versions.siblingNames.value,
|
||||
onlineSiblingNames: () => versions.onlineSiblingNames.value,
|
||||
hasUnsavedChanges: () => unsavedChanges.value,
|
||||
save: () => runSave({ announce: false }),
|
||||
onUpdated: updateArticle,
|
||||
onGone: markNotFound,
|
||||
})
|
||||
|
||||
const { choose: chooseVersion, loadingVisible: switching } = useVersionSwitch({
|
||||
currentName: () => versions.displayName.value,
|
||||
hasUnsavedChanges: () => unsavedChanges.value,
|
||||
save: () => runSave({ announce: false }),
|
||||
discard: () => draft.discardStored(),
|
||||
allowLeave: () => {
|
||||
leaving.value = true
|
||||
},
|
||||
cancelLeave: () => {
|
||||
leaving.value = false
|
||||
},
|
||||
})
|
||||
|
||||
function handlePublish() {
|
||||
setOnline(true)
|
||||
return versions.multilingual.value ? publishing.publish() : setOnline(true)
|
||||
}
|
||||
|
||||
function handleUnpublish() {
|
||||
setOnline(false)
|
||||
return versions.multilingual.value ? publishing.unpublish() : setOnline(false)
|
||||
}
|
||||
|
||||
function handleSaveAndPublish() {
|
||||
return versions.multilingual.value ? publishing.saveAndPublish() : setOnline(true)
|
||||
}
|
||||
|
||||
function handlePrimary() {
|
||||
const kind = plan.value.primary?.kind
|
||||
const actions = {
|
||||
save: performSave,
|
||||
trySaveAgain: performSave,
|
||||
saveAndPublish: handleSaveAndPublish,
|
||||
publish: handlePublish,
|
||||
retryPublish: publishing.retryPublish,
|
||||
}
|
||||
if (kind && kind in actions) {
|
||||
return actions[kind as keyof typeof actions]()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
@@ -349,8 +506,9 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
useUnsavedChangesGuard({
|
||||
isDirty: () => draft.state.value === 'dirty',
|
||||
isDirty: () => unsavedChanges.value && !leaving.value,
|
||||
title: () => article.value?.title ?? '',
|
||||
onDiscard: draft.discardStored,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -361,18 +519,6 @@ useUnsavedChangesGuard({
|
||||
:back-label="t('Back to Articles')"
|
||||
full-bleed
|
||||
>
|
||||
<template v-if="loadState === 'loaded'" #navbar-right>
|
||||
<UDropdownMenu :items="overflowItems">
|
||||
<UButton
|
||||
icon="i-lucide-ellipsis"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="size-11 justify-center lg:hidden"
|
||||
:ui="{ leadingIcon: 'size-5' }"
|
||||
:aria-label="t('More actions')"
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
<template v-if="loadState === 'loaded'" #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="[
|
||||
@@ -390,7 +536,12 @@ useUnsavedChangesGuard({
|
||||
</template>
|
||||
|
||||
<DefineDetailsPanel>
|
||||
<ArticleDetails v-if="article" :article="article" v-model:open="detailsOpen">
|
||||
<ArticleDetails
|
||||
v-if="article"
|
||||
:article="article"
|
||||
:multilingual="versions.multilingual.value"
|
||||
v-model:open="detailsOpen"
|
||||
>
|
||||
<template #meta="{ sheet }">
|
||||
<MetaSection
|
||||
:sites="sites"
|
||||
@@ -421,40 +572,83 @@ useUnsavedChangesGuard({
|
||||
</ArticleDetails>
|
||||
</DefineDetailsPanel>
|
||||
|
||||
<ErrorView v-if="loadState === 'notFound'" kind="article-not-found" />
|
||||
<EditorVersionGone v-if="loadState === 'notFound'" @back="goBack" />
|
||||
<ErrorView v-else-if="loadState === 'error'" kind="error" @retry="loadArticle" />
|
||||
|
||||
<EditorSkeleton v-else-if="loadState === 'loading' && showSkeleton" @back="goBack" />
|
||||
|
||||
<div v-else-if="loadState === 'loaded' && article" class="flex min-h-0 flex-1 flex-col">
|
||||
<EditorHeader
|
||||
v-model:preview="previewOn"
|
||||
:article="article"
|
||||
:state="draft.state.value"
|
||||
:saved-at="draft.savedAt.value"
|
||||
:count-text="countText"
|
||||
:save-enabled="saveEnabled"
|
||||
:publish-pending="publishPending"
|
||||
:publish-disabled="publishDisabled"
|
||||
:can-publish="canPublish"
|
||||
:can-delete="articleAccess.canDelete"
|
||||
:delete-reason="articleAccess.deleteReason"
|
||||
@back="goBack"
|
||||
@retry="retrySave"
|
||||
@save="performSave"
|
||||
@publish="handlePublish"
|
||||
@unpublish="handleUnpublish"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
<EditorHeader @back="goBack">
|
||||
<template #title>
|
||||
<EditorTitleField
|
||||
ref="titleField"
|
||||
:model-value="draft.title.value"
|
||||
:label="titleLabel(versions.sentenceName.value)"
|
||||
:lang="article.lang"
|
||||
:readonly="!canEdit"
|
||||
:changed="draft.title.value !== article.title"
|
||||
:saving="draft.savingTitle.value"
|
||||
:failed="titleSaveFailed"
|
||||
:server-error="draft.titleServerError.value"
|
||||
@update:model-value="editTitle"
|
||||
@retry="retryTitle"
|
||||
/>
|
||||
</template>
|
||||
<template #meta>
|
||||
<EditorLanguageMenu
|
||||
v-if="versions.multilingual.value"
|
||||
:rows="versions.rows.value"
|
||||
:name="versions.displayName.value"
|
||||
:code="versions.tag.value.toUpperCase()"
|
||||
@choose="chooseVersion"
|
||||
/>
|
||||
<LocaleBadge
|
||||
:tone="article.is_online ? 'success' : 'neutral'"
|
||||
:icon="article.is_online ? 'i-lucide-circle-check' : 'i-lucide-pencil'"
|
||||
>
|
||||
{{ article.is_online ? t('Published') : t('Draft') }}
|
||||
</LocaleBadge>
|
||||
<SaveStatus
|
||||
v-if="canEdit"
|
||||
:state="draft.state.value"
|
||||
:saved-at="draft.savedAt.value"
|
||||
:retryable="false"
|
||||
/>
|
||||
<span v-if="draftHint" class="text-xs text-muted max-lg:hidden">{{
|
||||
draftHint
|
||||
}}</span>
|
||||
<LocaleBadge v-if="!canEdit" tone="neutral" icon="i-lucide-lock">
|
||||
{{ t('Read only') }}
|
||||
</LocaleBadge>
|
||||
</template>
|
||||
<template #actions>
|
||||
<EditorActions
|
||||
v-model:preview="previewOn"
|
||||
:plan="plan"
|
||||
:language="versions.sentenceName.value"
|
||||
:offline="offline"
|
||||
:unpublish-visible="wideScreen"
|
||||
:overflow="overflow"
|
||||
:previewable="true"
|
||||
@save="performSave"
|
||||
@primary="handlePrimary"
|
||||
@unpublish="handleUnpublish"
|
||||
/>
|
||||
</template>
|
||||
</EditorHeader>
|
||||
|
||||
<div v-if="previewOn" class="flex min-h-0 flex-1">
|
||||
<EditorNotices :notices="notices" @retry-publish="publishing.retryPublish" />
|
||||
|
||||
<EditorSkeleton v-if="switching" headerless />
|
||||
|
||||
<div v-else-if="previewOn" class="flex min-h-0 flex-1">
|
||||
<div
|
||||
class="min-h-0 flex-1 overflow-y-auto bg-muted px-12 py-10"
|
||||
data-testid="article-preview"
|
||||
>
|
||||
<div class="article-content mx-auto">
|
||||
<h2 class="text-2xl font-semibold text-highlighted">
|
||||
{{ article.title }}
|
||||
{{ draft.title.value }}
|
||||
</h2>
|
||||
<div v-html="previewBody" />
|
||||
</div>
|
||||
@@ -471,12 +665,15 @@ useUnsavedChangesGuard({
|
||||
class="h-full"
|
||||
:model-value="draft.body.value"
|
||||
:article-id="articleId"
|
||||
:readonly="!canEdit"
|
||||
:label="t('Body')"
|
||||
@update:model-value="editBody"
|
||||
@request-image="onRequestImage"
|
||||
@request-remove-image="onRequestRemoveImage"
|
||||
>
|
||||
<template #footer>
|
||||
<EditorStatusBar
|
||||
v-if="canEdit"
|
||||
class="max-lg:hidden"
|
||||
:words="counts.words"
|
||||
:characters="counts.characters"
|
||||
@@ -534,15 +731,14 @@ useUnsavedChangesGuard({
|
||||
/>
|
||||
|
||||
<EditorMobileBar
|
||||
:online="article.is_online"
|
||||
:state="draft.state.value"
|
||||
:save-enabled="saveEnabled"
|
||||
:publish-pending="publishPending"
|
||||
:publish-disabled="publishDisabled"
|
||||
:can-publish="canPublish"
|
||||
:hint="draftHint"
|
||||
:plan="plan"
|
||||
:language="versions.sentenceName.value"
|
||||
:offline="offline"
|
||||
:overflow="overflow"
|
||||
@details="detailsOpen = true"
|
||||
@save="performSave"
|
||||
@publish="handlePublish"
|
||||
@primary="handlePrimary"
|
||||
@unpublish="handleUnpublish"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
WRITE_FAILS,
|
||||
failWith,
|
||||
kit,
|
||||
press,
|
||||
settle,
|
||||
text,
|
||||
typeInto,
|
||||
unmountRoute,
|
||||
} from './localizationKit'
|
||||
import {
|
||||
EDIT_ONLY,
|
||||
FULL,
|
||||
PUBLISH_ONLY,
|
||||
READ,
|
||||
articleOf,
|
||||
buttonNamed,
|
||||
editBody,
|
||||
mountEditor,
|
||||
openMoreActions,
|
||||
titleInput,
|
||||
type EditorFixture,
|
||||
} from './editorKit'
|
||||
import type { ArticleCapability } from '@/composables/useArticles'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const TABLET_WIDTH = 1024
|
||||
|
||||
beforeEach(() => {
|
||||
initializeLocale()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(unmountRoute)
|
||||
|
||||
function primary() {
|
||||
return document.body
|
||||
.querySelector<HTMLElement>('[data-testid="primary-action"]')
|
||||
?.textContent?.trim()
|
||||
}
|
||||
|
||||
function saveButton() {
|
||||
return document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')
|
||||
}
|
||||
|
||||
function menuItems() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
}
|
||||
|
||||
function menuItem(label: string) {
|
||||
return menuItems().find((item) => item.textContent?.includes(label))
|
||||
}
|
||||
|
||||
async function dirty() {
|
||||
await typeInto(titleInput(), 'Couts reels')
|
||||
}
|
||||
|
||||
interface Row {
|
||||
name: string
|
||||
fixture: EditorFixture
|
||||
edit?: boolean
|
||||
primary: string | undefined
|
||||
save: 'disabled' | 'enabled' | 'absent'
|
||||
unpublish?: boolean
|
||||
text?: string[]
|
||||
}
|
||||
|
||||
const ONLINE_ALERT = 'This version is online. Saving updates the live page right away.'
|
||||
|
||||
const MATRIX: Row[] = [
|
||||
{
|
||||
name: 'clean draft with edit and publish',
|
||||
fixture: {},
|
||||
primary: 'Publish French',
|
||||
save: 'disabled',
|
||||
},
|
||||
{
|
||||
name: 'clean draft with edit only',
|
||||
fixture: { caps: EDIT_ONLY },
|
||||
primary: undefined,
|
||||
save: 'disabled',
|
||||
text: ['Publishing needs the publish permission'],
|
||||
},
|
||||
{
|
||||
name: 'dirty draft with edit only',
|
||||
fixture: { caps: EDIT_ONLY },
|
||||
edit: true,
|
||||
primary: 'Save',
|
||||
save: 'absent',
|
||||
text: ['Saving keeps it as a draft.'],
|
||||
},
|
||||
{
|
||||
name: 'dirty draft with edit and publish',
|
||||
fixture: {},
|
||||
edit: true,
|
||||
primary: 'Save and publish French',
|
||||
save: 'enabled',
|
||||
},
|
||||
{
|
||||
name: 'clean online with edit and publish',
|
||||
fixture: { online: { fr: true } },
|
||||
primary: undefined,
|
||||
save: 'disabled',
|
||||
unpublish: true,
|
||||
text: [ONLINE_ALERT],
|
||||
},
|
||||
{
|
||||
name: 'dirty online with edit and publish',
|
||||
fixture: { online: { fr: true } },
|
||||
edit: true,
|
||||
primary: 'Save',
|
||||
save: 'absent',
|
||||
unpublish: true,
|
||||
text: [ONLINE_ALERT],
|
||||
},
|
||||
{
|
||||
name: 'publish without edit',
|
||||
fixture: { caps: PUBLISH_ONLY },
|
||||
primary: 'Publish French',
|
||||
save: 'absent',
|
||||
},
|
||||
{
|
||||
name: 'read only',
|
||||
fixture: { caps: READ },
|
||||
primary: undefined,
|
||||
save: 'absent',
|
||||
},
|
||||
{
|
||||
name: 'saving an online version without publish',
|
||||
fixture: { caps: EDIT_ONLY, online: { fr: true } },
|
||||
edit: true,
|
||||
primary: 'Save',
|
||||
save: 'absent',
|
||||
unpublish: false,
|
||||
text: [ONLINE_ALERT],
|
||||
},
|
||||
]
|
||||
|
||||
describe('loc.l5.*: Header priority and action matrix', () => {
|
||||
it.each(MATRIX)('$name', async (row) => {
|
||||
await mountEditor(row.fixture)
|
||||
if (row.edit) {
|
||||
await dirty()
|
||||
}
|
||||
|
||||
expect(primary()).toBe(row.primary)
|
||||
expect(
|
||||
saveButton() === null ? 'absent' : saveButton()!.disabled ? 'disabled' : 'enabled',
|
||||
).toBe(row.save)
|
||||
expect(buttonNamed('Unpublish French') !== undefined).toBe(row.unpublish ?? false)
|
||||
row.text?.forEach((fragment) => expect(text()).toContain(fragment))
|
||||
})
|
||||
|
||||
it('renders at most one filled button for every combination', async () => {
|
||||
const capabilitySets: ArticleCapability[][] = [READ, EDIT_ONLY, PUBLISH_ONLY, FULL]
|
||||
for (const caps of capabilitySets) {
|
||||
await mountEditor({ caps })
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="primary-action"]').length,
|
||||
).toBeLessThanOrEqual(1)
|
||||
await unmountRoute()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l5.saving and saveFail: Saving and save failed', () => {
|
||||
it('marks the primary busy while saving', async () => {
|
||||
await mountEditor({ extra: { 'PATCH /v1/article/12': () => new Promise(() => undefined) } })
|
||||
await dirty()
|
||||
await press('Save')
|
||||
|
||||
const busy = document.body.querySelector('[data-testid="primary-action"]')
|
||||
expect(busy?.textContent).toContain('Saving…')
|
||||
expect(busy?.getAttribute('aria-busy')).toBe('true')
|
||||
expect(saveButton()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the banner, "Try saving again" and publishes nothing when a save fails', async () => {
|
||||
await mountEditor({ extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
|
||||
await editBody()
|
||||
await press('Save')
|
||||
|
||||
expect(text()).toContain("Couldn't save French")
|
||||
expect(text()).toContain(
|
||||
'Your changes are kept here. Nothing was published. Try saving again.',
|
||||
)
|
||||
expect(primary()).toBe('Try saving again')
|
||||
})
|
||||
|
||||
it('reads Saved and offers Publish French after a good save', async () => {
|
||||
await mountEditor({
|
||||
extra: { 'PATCH /v1/article/12': { ...articleOf({}), title: 'Couts reels' } },
|
||||
})
|
||||
await dirty()
|
||||
await press('Save')
|
||||
|
||||
expect(text()).toContain('Saved')
|
||||
expect(primary()).toBe('Publish French')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l5.ovf: Collapse order by width', () => {
|
||||
const online = { online: { fr: true } }
|
||||
|
||||
it('keeps Unpublish French and More actions in the header at 1440', async () => {
|
||||
await mountEditor(online)
|
||||
|
||||
expect(buttonNamed('Unpublish French')).toBeDefined()
|
||||
expect(buttonNamed('More actions')).toBeDefined()
|
||||
})
|
||||
|
||||
it('moves Unpublish into More actions at 1024', async () => {
|
||||
await mountEditor({ ...online, width: TABLET_WIDTH })
|
||||
|
||||
expect(buttonNamed('Unpublish French')).toBeUndefined()
|
||||
await openMoreActions()
|
||||
expect(menuItem('Unpublish French')).toBeDefined()
|
||||
})
|
||||
|
||||
it('puts Save, the primary and an overflow button in the bottom bar at 390', async () => {
|
||||
await mountEditor({ ...online, width: MOBILE_WIDTH })
|
||||
await dirty()
|
||||
|
||||
const bar = document.body.querySelector<HTMLElement>('.border-t.lg\\:hidden')!
|
||||
expect(bar.textContent).toContain('Save')
|
||||
expect(bar.querySelector('[aria-label="More actions"]')).not.toBeNull()
|
||||
const targets = Array.from(bar.querySelectorAll('button'))
|
||||
expect(targets.every((target) => /max-md:h-11|size-11/.test(target.className))).toBe(true)
|
||||
})
|
||||
|
||||
it('opens the overflow as a sheet on a phone with Unpublish, Edit slug, Move and Delete', async () => {
|
||||
await mountEditor({ ...online, width: MOBILE_WIDTH })
|
||||
await openMoreActions()
|
||||
|
||||
expect(menuItems().map((item) => item.textContent?.trim())).toEqual([
|
||||
'Unpublish French',
|
||||
`Edit slug${NOT_AVAILABLE}`,
|
||||
`Move article${NOT_AVAILABLE}`,
|
||||
'Delete French version',
|
||||
`Delete article${NOT_AVAILABLE}`,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
const NOT_AVAILABLE = 'Not available yet.'
|
||||
|
||||
describe('loc.perm.createNotAll: The overflow menu follows capabilities', () => {
|
||||
it('lists Edit slug, Move article and both delete items for the owner', async () => {
|
||||
await mountEditor()
|
||||
await openMoreActions()
|
||||
|
||||
expect(menuItems().map((item) => item.textContent?.trim())).toEqual([
|
||||
`Edit slug${NOT_AVAILABLE}`,
|
||||
`Move article${NOT_AVAILABLE}`,
|
||||
'Delete French version',
|
||||
`Delete article${NOT_AVAILABLE}`,
|
||||
])
|
||||
})
|
||||
|
||||
it('shows Edit slug, Move article and Delete article disabled with a reason until they are wired', async () => {
|
||||
await mountEditor()
|
||||
await openMoreActions()
|
||||
|
||||
for (const label of ['Edit slug', 'Move article', 'Delete article']) {
|
||||
const item = menuItem(label)!
|
||||
expect(item.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(item.textContent).toContain(NOT_AVAILABLE)
|
||||
}
|
||||
expect(menuItem('Delete French version')!.getAttribute('aria-disabled')).toBeNull()
|
||||
})
|
||||
|
||||
it('disables Delete article and Move with the reason when a sibling lacks delete', async () => {
|
||||
await mountEditor({ siblingCaps: { en: EDIT_ONLY } })
|
||||
await openMoreActions()
|
||||
|
||||
for (const label of ['Move article', 'Delete article']) {
|
||||
const item = menuItem(label)!
|
||||
expect(item.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(item.textContent).toContain("You can't delete this article.")
|
||||
}
|
||||
expect(menuItem('Delete French version')!.getAttribute('aria-disabled')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no overflow button on a read-only version', async () => {
|
||||
await mountEditor({ caps: READ })
|
||||
|
||||
expect(buttonNamed('More actions')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.perm.*: Server checks still win', () => {
|
||||
it('keeps the edit and reads Save failed when PATCH answers 403', async () => {
|
||||
await mountEditor({
|
||||
caps: EDIT_ONLY,
|
||||
extra: { 'PATCH /v1/article/12': failWith(403, 'missing_permission') },
|
||||
})
|
||||
await dirty()
|
||||
await press('Save')
|
||||
|
||||
expect(text()).toContain('Save failed')
|
||||
expect(titleInput().value).toBe('Couts reels')
|
||||
expect(kit.calls.some((call) => call.method === 'PATCH')).toBe(true)
|
||||
await settle()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { settle, text, unmountRoute } from './localizationKit'
|
||||
import {
|
||||
EDIT_ONLY,
|
||||
FULL,
|
||||
READ,
|
||||
buttonNamed,
|
||||
languageTrigger,
|
||||
menuRows,
|
||||
mountEditor,
|
||||
openLanguageMenu,
|
||||
rowFor,
|
||||
titleInput,
|
||||
} from './editorKit'
|
||||
import type { LanguageFixture } from './localizationData'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeEach(() => initializeLocale())
|
||||
afterEach(unmountRoute)
|
||||
|
||||
const LANGUAGE_NAMES = ['English', 'Arabic', 'French', 'German']
|
||||
|
||||
function rowNames() {
|
||||
return menuRows().map((row) => row.textContent?.trim() ?? '')
|
||||
}
|
||||
|
||||
function languageOf(row: string) {
|
||||
return LANGUAGE_NAMES.find((name) => row.startsWith(name)) ?? row
|
||||
}
|
||||
|
||||
function languageRows(tags: string[]): LanguageFixture[] {
|
||||
return tags.map((lang) => ({ lang, versions: 1, published: 0 }))
|
||||
}
|
||||
|
||||
describe('loc.l5.menu: The menu lists readable versions in API order', () => {
|
||||
it('opens a menu, not a listbox, with English, Arabic and French and no German', async () => {
|
||||
await mountEditor({ caps: EDIT_ONLY })
|
||||
await openLanguageMenu()
|
||||
|
||||
expect(document.body.querySelector('[role="menu"]')).not.toBeNull()
|
||||
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
|
||||
expect(rowNames().map(languageOf)).toEqual(['English', 'Arabic', 'French'])
|
||||
})
|
||||
|
||||
it('names the control "Language: French. Switch version"', async () => {
|
||||
await mountEditor()
|
||||
|
||||
expect(languageTrigger()?.getAttribute('aria-label')).toBe(
|
||||
'Language: French. Switch version',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows each language name, code and publication badge, with the French row current', async () => {
|
||||
await mountEditor({ caps: EDIT_ONLY })
|
||||
await openLanguageMenu()
|
||||
|
||||
expect(rowNames()).toEqual([
|
||||
expect.stringMatching(/^EnglishEN.*Published/),
|
||||
expect.stringMatching(/^ArabicAR.*Published/),
|
||||
expect.stringMatching(/^FrenchFR.*Draft/),
|
||||
])
|
||||
expect(rowFor('French')?.querySelector('[aria-current="true"]')).not.toBeNull()
|
||||
expect(rowFor('English')?.querySelector('[aria-current="true"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l5.two and loc.l5.many: Menu size', () => {
|
||||
it('shows two rows without a filter on a two-language site', async () => {
|
||||
await mountEditor({
|
||||
caps: EDIT_ONLY,
|
||||
translations: ['en', 'fr'],
|
||||
languages: languageRows(['en', 'fr']),
|
||||
})
|
||||
await openLanguageMenu()
|
||||
|
||||
expect(menuRows()).toHaveLength(2)
|
||||
expect(document.body.querySelector('input[placeholder="Find a language"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows all four rows without a filter', async () => {
|
||||
await mountEditor({ caps: EDIT_ONLY, translations: ['en', 'fr', 'de', 'ar'] })
|
||||
await openLanguageMenu()
|
||||
|
||||
expect(menuRows()).toHaveLength(4)
|
||||
expect(document.body.querySelector('input[placeholder="Find a language"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('adds a filter field that narrows the rows by language name on a many-language site', async () => {
|
||||
const tags = ['en', 'fr', 'de', 'ar', 'es', 'it', 'ja', 'nl', 'pl', 'pt-BR']
|
||||
await mountEditor({
|
||||
caps: FULL,
|
||||
translations: ['en', 'fr', 'de', 'ar'],
|
||||
languages: languageRows(tags),
|
||||
})
|
||||
await openLanguageMenu()
|
||||
|
||||
const filter = document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="Find a language"]',
|
||||
)!
|
||||
expect(menuRows().length).toBeGreaterThan(8)
|
||||
filter.value = 'port'
|
||||
filter.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await settle()
|
||||
|
||||
expect(rowNames().map((name) => name.slice(0, 'Portuguese'.length))).toEqual(['Portuguese'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.perm.*: Row tags and absent rows follow each sibling capabilities', () => {
|
||||
it('tags a read-only sibling as Read only and still opens it', async () => {
|
||||
await mountEditor({ siblingCaps: { ar: READ } })
|
||||
await openLanguageMenu()
|
||||
|
||||
expect(rowFor('Arabic')?.textContent).toContain('Read only')
|
||||
expect(rowFor('English')?.textContent).not.toContain('Read only')
|
||||
})
|
||||
|
||||
it('shows no German row, count or placeholder when German is not in translations', async () => {
|
||||
await mountEditor({ caps: EDIT_ONLY })
|
||||
await openLanguageMenu()
|
||||
|
||||
expect(rowFor('German')).toBeUndefined()
|
||||
expect(text()).not.toContain('German')
|
||||
})
|
||||
|
||||
it('keeps a disabled language listed and marks it as disabled', async () => {
|
||||
await mountEditor({ languageEnabled: { ar: false } })
|
||||
await openLanguageMenu()
|
||||
|
||||
expect(rowFor('Arabic')?.textContent).toContain('Disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l5b.withAdd, noAdd and roMenu: Add translation rows', () => {
|
||||
it('lists missing languages as Add translation rows when the caller can create', async () => {
|
||||
await mountEditor({ caps: FULL })
|
||||
await openLanguageMenu()
|
||||
|
||||
expect(rowFor('German')?.textContent).toContain('Add translation')
|
||||
})
|
||||
|
||||
it('offers nothing for missing languages without create_translation', async () => {
|
||||
await mountEditor({ caps: EDIT_ONLY })
|
||||
await openLanguageMenu()
|
||||
|
||||
expect(text()).not.toContain('Add translation')
|
||||
})
|
||||
|
||||
it('offers no Add translation row on a read-only version', async () => {
|
||||
await mountEditor({ caps: READ })
|
||||
await openLanguageMenu()
|
||||
|
||||
expect(text()).not.toContain('Add translation')
|
||||
})
|
||||
|
||||
it('renders the Add translation row without doing anything when chosen', async () => {
|
||||
await mountEditor({ caps: FULL })
|
||||
await openLanguageMenu()
|
||||
rowFor('German')!.click()
|
||||
await settle()
|
||||
|
||||
expect(titleInput().value).toBe('Couts de renovation')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The menu is keyboard operable', () => {
|
||||
it('opens on ArrowDown with focus on the current row', async () => {
|
||||
await mountEditor()
|
||||
languageTrigger()!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
|
||||
)
|
||||
await settle()
|
||||
|
||||
expect(document.body.querySelector('[role="menu"]')).not.toBeNull()
|
||||
expect(document.activeElement?.textContent).toContain('French')
|
||||
})
|
||||
|
||||
it('focuses the current row when the sheet opens on a phone', async () => {
|
||||
await mountEditor({ width: MOBILE_WIDTH })
|
||||
await openLanguageMenu()
|
||||
|
||||
expect(document.activeElement?.textContent).toContain('French')
|
||||
})
|
||||
|
||||
it('closes on Escape', async () => {
|
||||
await mountEditor()
|
||||
await openLanguageMenu()
|
||||
document.activeElement?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
)
|
||||
await settle()
|
||||
|
||||
expect(document.body.querySelector('[role="menu"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A single-language site shows no language control', () => {
|
||||
it('hides the control, names no language and reads Publish', async () => {
|
||||
await mountEditor({
|
||||
lang: 'en',
|
||||
translations: ['en'],
|
||||
languages: languageRows(['en']),
|
||||
online: { en: false },
|
||||
})
|
||||
|
||||
expect(languageTrigger()).toBeNull()
|
||||
expect(buttonNamed('Publish')).toBeDefined()
|
||||
expect(text()).not.toContain('English')
|
||||
expect(titleInput().getAttribute('id')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('On a phone the menu opens as a sheet', () => {
|
||||
it('lists the same rows in a bottom sheet', async () => {
|
||||
await mountEditor({ width: MOBILE_WIDTH, caps: EDIT_ONLY })
|
||||
languageTrigger()!.click()
|
||||
await settle()
|
||||
|
||||
expect(rowNames().map(languageOf)).toEqual(['English', 'Arabic', 'French'])
|
||||
})
|
||||
})
|
||||
@@ -109,7 +109,7 @@ describe('Editor section: loading and not found', () => {
|
||||
backend.responders['GET /v1/article/12'] = failWith(404)
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
|
||||
expect(textOf(document.body)).toContain("This article doesn't exist")
|
||||
expect(textOf(document.body)).toContain("This version isn't available")
|
||||
expect(metaSection()).toBeNull()
|
||||
expect(document.body.textContent).not.toContain('Site and authors')
|
||||
})
|
||||
@@ -454,12 +454,9 @@ describe('Editor section: the existing save behaviour treats the change like any
|
||||
saveButtonEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(JSON.parse(sessionStorage.getItem('renewcms:article-draft-meta:12')!)).toMatchObject(
|
||||
{
|
||||
site_id: 2,
|
||||
},
|
||||
)
|
||||
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBeNull()
|
||||
const stored = JSON.parse(sessionStorage.getItem('renewcms:article-draft:1:12')!)
|
||||
expect(stored.meta).toMatchObject({ site_id: 2 })
|
||||
expect(stored.body).toBeUndefined()
|
||||
await unmountMeta()
|
||||
|
||||
await mountMeta('editor', EDITOR, {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import { callsTo, kit, settle, text, unmountRoute } from './localizationKit'
|
||||
import {
|
||||
EDIT_ONLY,
|
||||
PUBLISH_ONLY,
|
||||
READ,
|
||||
buttonNamed,
|
||||
mountEditor,
|
||||
titleInput,
|
||||
toolbar,
|
||||
} from './editorKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
initializeLocale()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(unmountRoute)
|
||||
|
||||
function pressControlS() {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true }))
|
||||
}
|
||||
|
||||
function bodyEditor() {
|
||||
return document.body.querySelector('[role="textbox"][aria-multiline="true"]')
|
||||
}
|
||||
|
||||
describe('loc.perm.readonly: A version without edit renders read-only', () => {
|
||||
it('reads the banner, locks the title and body, hides the toolbar and every action', async () => {
|
||||
await mountEditor({ caps: READ })
|
||||
|
||||
expect(text()).toContain("You can read this version. You can't edit it.")
|
||||
expect(text()).toContain(
|
||||
'The source reference and the versions you can read stay available.',
|
||||
)
|
||||
expect(titleInput().getAttribute('aria-readonly')).toBe('true')
|
||||
expect(titleInput().readOnly).toBe(true)
|
||||
expect(bodyEditor()?.getAttribute('aria-readonly')).toBe('true')
|
||||
expect(toolbar()).toBeNull()
|
||||
for (const label of [
|
||||
'Save',
|
||||
'Publish French',
|
||||
'Unpublish French',
|
||||
'Delete French version',
|
||||
]) {
|
||||
expect(buttonNamed(label)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('does nothing on Ctrl+S', async () => {
|
||||
await mountEditor({ caps: READ })
|
||||
pressControlS()
|
||||
await settle()
|
||||
|
||||
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(0)
|
||||
expect(kit.calls.some((call) => call.method === 'PATCH')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.perm.publishNoEdit: Publish without edit', () => {
|
||||
it('reads the notice, keeps the fields read-only and offers Publish French', async () => {
|
||||
await mountEditor({ caps: PUBLISH_ONLY })
|
||||
|
||||
expect(text()).toContain('You can publish this version but not edit it')
|
||||
expect(text()).toContain('Fields are read-only. Publish stays available.')
|
||||
expect(titleInput().readOnly).toBe(true)
|
||||
expect(toolbar()).toBeNull()
|
||||
expect(buttonNamed('Publish French')).toBeDefined()
|
||||
expect(buttonNamed('Save')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.perm.editNoPublish: Edit without publish', () => {
|
||||
it('reads the hint, offers Save and no Publish control', async () => {
|
||||
await mountEditor({ caps: EDIT_ONLY })
|
||||
|
||||
expect(text()).toContain('Publishing needs the publish permission')
|
||||
expect(text()).toContain('Saving keeps it as a draft.')
|
||||
expect(buttonNamed('Save')).toBeDefined()
|
||||
expect(buttonNamed('Publish French')).toBeUndefined()
|
||||
expect(toolbar()).not.toBeNull()
|
||||
expect(titleInput().readOnly).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import {
|
||||
WRITE_FAILS,
|
||||
callsTo,
|
||||
dialogBox,
|
||||
dialogText,
|
||||
failWith,
|
||||
kit,
|
||||
press,
|
||||
settle,
|
||||
text,
|
||||
toastText,
|
||||
typeInto,
|
||||
unmountRoute,
|
||||
} from './localizationKit'
|
||||
import { articleOf, buttonNamed, editBody, mountEditor, titleInput } from './editorKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const THREE = ['en', 'fr', 'de'] as const
|
||||
|
||||
beforeEach(() => {
|
||||
initializeLocale()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(unmountRoute)
|
||||
|
||||
const saved = () => articleOf({ translations: [...THREE] })
|
||||
const published = () => articleOf({ translations: [...THREE], online: { fr: true } })
|
||||
|
||||
function requests() {
|
||||
return kit.calls
|
||||
.filter((call) => call.method === 'PATCH' || call.method === 'DELETE')
|
||||
.map((call) => `${call.method} ${call.path}`)
|
||||
}
|
||||
|
||||
async function openDialogFor(label: string) {
|
||||
await press(label)
|
||||
expect(dialogBox()).not.toBeNull()
|
||||
}
|
||||
|
||||
describe('loc.l8.saveAndPublish: Save and publish saves first, then publishes only that version', () => {
|
||||
function setup(extra: Record<string, unknown> = {}) {
|
||||
return mountEditor({
|
||||
translations: [...THREE],
|
||||
extra: {
|
||||
'PATCH /v1/article/12': saved(),
|
||||
'PATCH /v1/article/12/publish': published(),
|
||||
...extra,
|
||||
} as never,
|
||||
})
|
||||
}
|
||||
|
||||
it('explains the order in the confirmation dialog', async () => {
|
||||
await setup()
|
||||
await editBody()
|
||||
await openDialogFor('Save and publish French')
|
||||
|
||||
expect(dialogText()).toContain('Save and publish French?')
|
||||
expect(dialogText()).toContain(
|
||||
"Your unsaved changes to the French version are saved first, then only the French version goes online. English and German aren't affected.",
|
||||
)
|
||||
})
|
||||
|
||||
it('sends the save, then the publish, touches no other article and toasts', async () => {
|
||||
await setup()
|
||||
await editBody()
|
||||
await openDialogFor('Save and publish French')
|
||||
await press('Save and publish French', dialogBox()!)
|
||||
|
||||
expect(requests()).toEqual(['PATCH /v1/article/12', 'PATCH /v1/article/12/publish'])
|
||||
await vi.waitFor(() => expect(toastText()).toContain('French published'))
|
||||
expect(toastText()).toContain("Other versions weren't changed.")
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l8.saveFailPublish: A failed save publishes nothing', () => {
|
||||
it('toasts, shows the banner and the retry primary, and never calls publish', async () => {
|
||||
await mountEditor({
|
||||
translations: [...THREE],
|
||||
extra: { 'PATCH /v1/article/12': WRITE_FAILS },
|
||||
})
|
||||
await editBody()
|
||||
await openDialogFor('Save and publish French')
|
||||
await press('Save and publish French', dialogBox()!)
|
||||
|
||||
expect(requests()).toEqual(['PATCH /v1/article/12'])
|
||||
await vi.waitFor(() => expect(toastText()).toContain("Couldn't save French"))
|
||||
expect(toastText()).toContain('Nothing was published. Your changes are kept. Try again.')
|
||||
expect(text()).toContain(
|
||||
'Your changes are kept here. Nothing was published. Try saving again.',
|
||||
)
|
||||
expect(buttonNamed('Try saving again')).toBeDefined()
|
||||
expect(text()).toContain('Save failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l5.pubFail: A failed publish after a good save is not a failed save', () => {
|
||||
async function failAfterSave() {
|
||||
await mountEditor({
|
||||
translations: [...THREE],
|
||||
extra: {
|
||||
'PATCH /v1/article/12': saved(),
|
||||
'PATCH /v1/article/12/publish': failWith(500, 'Internal server error'),
|
||||
},
|
||||
})
|
||||
await editBody()
|
||||
await openDialogFor('Save and publish French')
|
||||
await press('Save and publish French', dialogBox()!)
|
||||
}
|
||||
|
||||
it('says the draft is saved and offers Retry publish', async () => {
|
||||
await failAfterSave()
|
||||
|
||||
expect(text()).toContain('Saved as a draft. Publishing failed. Try publishing again.')
|
||||
expect(text()).toContain('Your edits are saved. Only the publishing step failed.')
|
||||
expect(text()).toContain('Saved')
|
||||
expect(text()).not.toContain('Save failed')
|
||||
expect(buttonNamed('Retry publish')).toBeDefined()
|
||||
})
|
||||
|
||||
it('sends only the publish request on Retry publish', async () => {
|
||||
await failAfterSave()
|
||||
await press('Retry publish')
|
||||
|
||||
expect(requests()).toEqual([
|
||||
'PATCH /v1/article/12',
|
||||
'PATCH /v1/article/12/publish',
|
||||
'PATCH /v1/article/12/publish',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.lang.publishDisabled: Publishing is blocked in a disabled language', () => {
|
||||
it('warns after a 422 language_disabled and keeps Save working', async () => {
|
||||
await mountEditor({
|
||||
translations: [...THREE],
|
||||
extra: {
|
||||
'PATCH /v1/article/12': saved(),
|
||||
'PATCH /v1/article/12/publish': failWith(422, 'language_disabled'),
|
||||
},
|
||||
})
|
||||
await openDialogFor('Publish French')
|
||||
await press('Publish French', dialogBox()!)
|
||||
|
||||
expect(text()).toContain('French is disabled on this site. Re-enable it to publish.')
|
||||
expect(text()).toContain(
|
||||
'You can still edit and save this version. Ask an owner to re-enable French.',
|
||||
)
|
||||
await typeInto(titleInput(), 'Couts reels')
|
||||
await press('Save')
|
||||
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the warning up front when the version language is disabled', async () => {
|
||||
await mountEditor({ translations: [...THREE], languageEnabled: { fr: false } })
|
||||
|
||||
expect(text()).toContain('French is disabled on this site. Re-enable it to publish.')
|
||||
expect((buttonNamed('Publish French') as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l8.publish, unpublish and fail: Publishing a clean version', () => {
|
||||
it('publishes French only after confirming the dialog', async () => {
|
||||
await mountEditor({
|
||||
translations: [...THREE],
|
||||
extra: { 'PATCH /v1/article/12/publish': published() },
|
||||
})
|
||||
await openDialogFor('Publish French')
|
||||
|
||||
expect(dialogText()).toContain('Publish French?')
|
||||
expect(dialogText()).toContain(
|
||||
"Only the French version goes online. English and German aren't affected.",
|
||||
)
|
||||
await press('Publish French', dialogBox()!)
|
||||
expect(requests()).toEqual(['PATCH /v1/article/12/publish'])
|
||||
})
|
||||
|
||||
it('unpublishes French only after confirming the dialog', async () => {
|
||||
await mountEditor({
|
||||
translations: [...THREE],
|
||||
online: { fr: true },
|
||||
extra: { 'PATCH /v1/article/12/unpublish': saved() },
|
||||
})
|
||||
await openDialogFor('Unpublish French')
|
||||
|
||||
expect(dialogText()).toContain('Unpublish French?')
|
||||
expect(dialogText()).toContain(
|
||||
'The French version goes back to draft and stops being public. English stays online.',
|
||||
)
|
||||
await press('Unpublish French', dialogBox()!)
|
||||
expect(requests()).toEqual(['PATCH /v1/article/12/unpublish'])
|
||||
})
|
||||
|
||||
it('toasts "Couldn\'t publish French" when a clean publish fails', async () => {
|
||||
await mountEditor({
|
||||
translations: [...THREE],
|
||||
extra: { 'PATCH /v1/article/12/publish': WRITE_FAILS },
|
||||
})
|
||||
await openDialogFor('Publish French')
|
||||
await press('Publish French', dialogBox()!)
|
||||
|
||||
await vi.waitFor(() => expect(toastText()).toContain("Couldn't publish French"))
|
||||
expect(toastText()).toContain('Nothing changed. Try again.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l8.unpublishDirty: Unpublish never saves local edits', () => {
|
||||
it('explains that edits stay, sends only /unpublish and keeps the state unsaved', async () => {
|
||||
await mountEditor({
|
||||
translations: [...THREE],
|
||||
online: { fr: true },
|
||||
extra: { 'PATCH /v1/article/12/unpublish': saved() },
|
||||
})
|
||||
await typeInto(titleInput(), 'Couts reels')
|
||||
await openDialogFor('Unpublish French')
|
||||
|
||||
expect(dialogText()).toContain(
|
||||
'The saved French version goes offline and becomes a draft. English stays online.',
|
||||
)
|
||||
expect(dialogText()).toContain('Your edits stay in the editor')
|
||||
expect(dialogText()).toContain(
|
||||
'Your unsaved changes are kept here. Nothing is saved when you unpublish. Save them yourself when you are ready.',
|
||||
)
|
||||
await press('Unpublish French', dialogBox()!)
|
||||
await settle()
|
||||
|
||||
expect(requests()).toEqual(['PATCH /v1/article/12/unpublish'])
|
||||
expect(titleInput().value).toBe('Couts reels')
|
||||
expect(text()).toContain('Unsaved changes')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import {
|
||||
WRITE_FAILS,
|
||||
failWith,
|
||||
kit,
|
||||
press,
|
||||
settle,
|
||||
text,
|
||||
typeInto,
|
||||
unmountRoute,
|
||||
} from './localizationKit'
|
||||
import { EDIT_ONLY, READ, articleOf, editBody, mountEditor, titleInput } from './editorKit'
|
||||
import { removeStoredDraftsOf } from '@/lib/storedDraft'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const KEY_FOR_7 = 'renewcms:article-draft:7:12'
|
||||
const STORED = {
|
||||
title: 'Stored title',
|
||||
body: '<p>Stored body</p>',
|
||||
mirrored_at: '2026-10-14T14:18:00Z',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initializeLocale()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(unmountRoute)
|
||||
|
||||
function store(key = KEY_FOR_7) {
|
||||
sessionStorage.setItem(key, JSON.stringify(STORED))
|
||||
}
|
||||
|
||||
function editorBody() {
|
||||
return kit.wrapper!.findComponent({ name: 'ArticleEditor' }).props('modelValue')
|
||||
}
|
||||
|
||||
describe('A 401 on save mirrors the draft for this user and article', () => {
|
||||
it('stores the title and body under user 7 and article 12 with the mirror time', async () => {
|
||||
await mountEditor({ extra: { 'PATCH /v1/article/12': failWith(401) } })
|
||||
await typeInto(titleInput(), 'Couts reels')
|
||||
await editBody('<p>Edited body</p>')
|
||||
await press('Save')
|
||||
|
||||
const stored = JSON.parse(sessionStorage.getItem(KEY_FOR_7)!)
|
||||
expect(stored).toMatchObject({ title: 'Couts reels', body: '<p>Edited body</p>' })
|
||||
expect(new Date(stored.mirrored_at).getTime()).not.toBeNaN()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l11.restored: Restoring after login', () => {
|
||||
it('shows the stored title and body, not the loaded ones, with the info alert and unsaved state', async () => {
|
||||
store()
|
||||
await mountEditor()
|
||||
|
||||
expect(titleInput().value).toBe('Stored title')
|
||||
expect(editorBody()).toBe('<p>Stored body</p>')
|
||||
expect(text()).toContain('Restored unsaved changes from 14:18')
|
||||
expect(text()).toContain(
|
||||
'You logged in again. Your French title and body were kept in this browser.',
|
||||
)
|
||||
expect(text()).toContain('Unsaved changes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A draft never leaks across accounts or articles', () => {
|
||||
it('shows the loaded article unchanged for another account', async () => {
|
||||
store()
|
||||
await mountEditor({ userId: 8 })
|
||||
|
||||
expect(titleInput().value).toBe('Couts de renovation')
|
||||
expect(text()).not.toContain('Restored unsaved changes')
|
||||
expect(sessionStorage.getItem(KEY_FOR_7)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('restores nothing on another article of the same account', async () => {
|
||||
store()
|
||||
await mountEditor({ lang: 'en' })
|
||||
|
||||
expect(titleInput().value).toBe('Renovation costs')
|
||||
expect(text()).not.toContain('Restored unsaved changes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('When the local draft is deleted', () => {
|
||||
it('removes it after a save answers 200', async () => {
|
||||
store()
|
||||
await mountEditor({ extra: { 'PATCH /v1/article/12': articleOf({}) } })
|
||||
await press('Save')
|
||||
|
||||
expect(sessionStorage.getItem(KEY_FOR_7)).toBeNull()
|
||||
})
|
||||
|
||||
it('removes it when GET answers 404', async () => {
|
||||
store()
|
||||
await mountEditor({ extra: { 'GET /v1/article/12': failWith(404, 'Article not found') } })
|
||||
|
||||
expect(sessionStorage.getItem(KEY_FOR_7)).toBeNull()
|
||||
expect(text()).toContain("This version isn't available")
|
||||
})
|
||||
|
||||
it('removes it and never renders it when the response has no edit capability', async () => {
|
||||
store()
|
||||
await mountEditor({ caps: READ })
|
||||
|
||||
expect(sessionStorage.getItem(KEY_FOR_7)).toBeNull()
|
||||
expect(titleInput().value).toBe('Couts de renovation')
|
||||
expect(text()).not.toContain('Restored unsaved changes')
|
||||
})
|
||||
|
||||
it('removes every draft of the user on an explicit logout', () => {
|
||||
sessionStorage.setItem(KEY_FOR_7, '{}')
|
||||
sessionStorage.setItem('renewcms:article-draft:7:30', '{}')
|
||||
sessionStorage.setItem('renewcms:article-draft:8:12', '{}')
|
||||
removeStoredDraftsOf(7)
|
||||
|
||||
expect(sessionStorage.getItem(KEY_FOR_7)).toBeNull()
|
||||
expect(sessionStorage.getItem('renewcms:article-draft:7:30')).toBeNull()
|
||||
expect(sessionStorage.getItem('renewcms:article-draft:8:12')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps it when a save fails with an ordinary error', async () => {
|
||||
store()
|
||||
await mountEditor({ caps: EDIT_ONLY, extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
|
||||
await press('Save')
|
||||
await settle()
|
||||
|
||||
expect(sessionStorage.getItem(KEY_FOR_7)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -152,8 +152,10 @@ describe('Saving state', () => {
|
||||
expect(fieldByLabel(panel, 'Meta title')!.className).toContain('disabled:opacity-100')
|
||||
expect(fieldByLabel(panel, 'Meta title')!.className).not.toContain('opacity-40')
|
||||
expect(
|
||||
textOf(document.body.querySelector('[data-testid="save-button"]')!.parentElement),
|
||||
).toContain('Save')
|
||||
document.body
|
||||
.querySelector('[data-testid="primary-action"]')!
|
||||
.getAttribute('aria-busy'),
|
||||
).toBe('true')
|
||||
expect(document.body.textContent).toContain('Saving…')
|
||||
gate.resolve({})
|
||||
await flushPromises()
|
||||
@@ -212,7 +214,7 @@ describe('Save failed', () => {
|
||||
expect(fieldByLabel(panel, 'Meta title')!.disabled).toBe(false)
|
||||
expect(saveChangesButton(panel).disabled).toBe(false)
|
||||
expect(document.body.textContent).toContain('Save failed')
|
||||
expect(document.body.textContent).toContain('Retry')
|
||||
expect(document.body.textContent).toContain('Try saving again')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { typeInto } from './directoryTestKit'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { fieldByLabel, openPanel, panelRoot, saveChangesButton } from './seoTestKit'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
import type { ArticleCapability } from '@/lib/articleTypes'
|
||||
|
||||
function reply(status: number, body: unknown = null, text = '') {
|
||||
return {
|
||||
@@ -26,6 +27,7 @@ const article = {
|
||||
title: 'How we price a bathroom renovation',
|
||||
body: '<p>Intro</p>',
|
||||
is_online: false,
|
||||
capabilities: ['read', 'edit', 'publish', 'delete'] as ArticleCapability[],
|
||||
site_id: 1,
|
||||
authors: [{ id: 2, name: 'Inès Duarte', slug: 'ines-duarte' }],
|
||||
slug: 'how-we-price-a-bathroom-renovation',
|
||||
@@ -71,7 +73,7 @@ async function mountEditor() {
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.session = { id: 1, verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.status = 'ready'
|
||||
await router.push('/article/12/edit')
|
||||
wrapper = mount(RootComponent, {
|
||||
@@ -112,7 +114,7 @@ describe('A session problem during the check follows the existing rules', () =>
|
||||
|
||||
await mountEditor()
|
||||
|
||||
expect(document.body.textContent).toContain("This article doesn't exist.")
|
||||
expect(document.body.textContent).toContain("This version isn't available")
|
||||
expect(document.body.querySelector('[data-testid="seo-section"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -129,7 +131,7 @@ describe('Session expiry keeps the SEO draft', () => {
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
|
||||
const mirrored = JSON.parse(sessionStorage.getItem('renewcms:article-draft-seo:12')!)
|
||||
const mirrored = JSON.parse(sessionStorage.getItem('renewcms:article-draft:1:12')!).seo
|
||||
expect(mirrored.seo_title).toBe('A title kept across the login')
|
||||
|
||||
wrapper?.unmount()
|
||||
|
||||
@@ -332,6 +332,6 @@ describe('The check could not run', () => {
|
||||
backend.responders['POST /v1/article/12/seo/check'] = failWith(404)
|
||||
await mountSeo(backend)
|
||||
|
||||
expect(document.body.textContent).toContain("This article doesn't exist.")
|
||||
expect(document.body.textContent).toContain("This version isn't available")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import {
|
||||
WRITE_FAILS,
|
||||
callsTo,
|
||||
deferred,
|
||||
failWith,
|
||||
press,
|
||||
settle,
|
||||
text,
|
||||
typeInto,
|
||||
unmountRoute,
|
||||
} from './localizationKit'
|
||||
import {
|
||||
TITLES,
|
||||
articleOf,
|
||||
buttonNamed,
|
||||
editBody,
|
||||
mountEditor,
|
||||
openLanguageMenu,
|
||||
rowFor,
|
||||
titleInput,
|
||||
} from './editorKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const HELPER = "Changing the title doesn't change the address."
|
||||
const TITLE_REJECTED = 'Article title must be between 3 and 200 characters.'
|
||||
|
||||
beforeEach(() => {
|
||||
initializeLocale()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(unmountRoute)
|
||||
|
||||
function helper() {
|
||||
return document.getElementById(titleInput().getAttribute('aria-describedby')!)
|
||||
}
|
||||
|
||||
function saveEnabled() {
|
||||
return !(buttonNamed('Save') as HTMLButtonElement | undefined)?.disabled
|
||||
}
|
||||
|
||||
function primaryDisabled() {
|
||||
return (document.body.querySelector('[data-testid="primary-action"]') as HTMLButtonElement)
|
||||
.disabled
|
||||
}
|
||||
|
||||
async function focusField() {
|
||||
titleInput().focus()
|
||||
titleInput().dispatchEvent(new FocusEvent('focus'))
|
||||
await settle()
|
||||
}
|
||||
|
||||
describe('loc.l5.title: The title replaces the heading and the old tooltip', () => {
|
||||
it('is a text field labelled "Title (French)" holding the title', async () => {
|
||||
await mountEditor()
|
||||
|
||||
expect(titleInput().tagName).toBe('TEXTAREA')
|
||||
expect(titleInput().value).toBe(TITLES.fr)
|
||||
expect(document.body.querySelector('label')?.textContent).toBe('Title (French)')
|
||||
})
|
||||
|
||||
it('has no tooltip about the title being fixed anywhere', async () => {
|
||||
await mountEditor()
|
||||
|
||||
expect(document.body.innerHTML).not.toContain("The title can't be changed after creation.")
|
||||
expect(document.body.querySelector('[title]')?.getAttribute('title') ?? '').not.toContain(
|
||||
'title',
|
||||
)
|
||||
})
|
||||
|
||||
it('reads "Title" on a single-language site', async () => {
|
||||
await mountEditor({
|
||||
lang: 'en',
|
||||
translations: ['en'],
|
||||
languages: [{ lang: 'en', versions: 1, published: 1 }],
|
||||
})
|
||||
|
||||
expect(document.body.querySelector('label')?.textContent).toBe('Title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Title field states', () => {
|
||||
it('loc.l5.default: is neutral with no helper and Save disabled', async () => {
|
||||
await mountEditor()
|
||||
|
||||
expect(helper()?.textContent?.trim()).toBe('')
|
||||
expect(titleInput().getAttribute('aria-invalid')).toBeNull()
|
||||
expect(saveEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it('loc.l5.focus: shows the address note with Save still disabled', async () => {
|
||||
await mountEditor()
|
||||
await focusField()
|
||||
|
||||
expect(helper()?.textContent).toContain(HELPER)
|
||||
expect(saveEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it('loc.l5.titleEdit: shows the counter "11 / 200" and enables Save while typing', async () => {
|
||||
await mountEditor()
|
||||
await focusField()
|
||||
await typeInto(titleInput(), 'Couts reels')
|
||||
|
||||
expect(text()).toContain('11 / 200')
|
||||
expect(helper()?.textContent).toContain(HELPER)
|
||||
expect(saveEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
it('loc.l5.titleUnsaved: reads Unsaved changes once focus leaves, Save stays enabled', async () => {
|
||||
await mountEditor()
|
||||
await focusField()
|
||||
await typeInto(titleInput(), 'Couts reels')
|
||||
titleInput().dispatchEvent(new FocusEvent('blur'))
|
||||
await settle()
|
||||
|
||||
expect(text()).toContain('Unsaved changes')
|
||||
expect(helper()?.textContent).toContain(HELPER)
|
||||
expect(saveEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
it('loc.l5.invalid: flags an empty title as an alert and blocks Save', async () => {
|
||||
await mountEditor()
|
||||
await typeInto(titleInput(), '')
|
||||
|
||||
expect(titleInput().getAttribute('aria-invalid')).toBe('true')
|
||||
expect(helper()?.getAttribute('role')).toBe('alert')
|
||||
expect(helper()?.textContent).toContain('Enter a title')
|
||||
expect(primaryDisabled()).toBe(true)
|
||||
})
|
||||
|
||||
it('loc.l5.titleShort: flags two characters with the counter "2 / 200"', async () => {
|
||||
await mountEditor()
|
||||
await typeInto(titleInput(), 'ab')
|
||||
|
||||
expect(helper()?.textContent).toContain('Use at least 3 characters')
|
||||
expect(text()).toContain('2 / 200')
|
||||
expect(primaryDisabled()).toBe(true)
|
||||
})
|
||||
|
||||
it('loc.l5.titleLong: flags 203 characters with the counter "203 / 200"', async () => {
|
||||
await mountEditor()
|
||||
await typeInto(titleInput(), 'a'.repeat(203))
|
||||
|
||||
expect(helper()?.textContent).toContain('Use 200 characters or fewer')
|
||||
expect(text()).toContain('203 / 200')
|
||||
expect(primaryDisabled()).toBe(true)
|
||||
})
|
||||
|
||||
it('counts code points: 200 four-byte emoji are valid', async () => {
|
||||
await mountEditor()
|
||||
await focusField()
|
||||
await typeInto(titleInput(), '😀'.repeat(200))
|
||||
|
||||
expect(text()).toContain('200 / 200')
|
||||
expect(titleInput().getAttribute('aria-invalid')).toBeNull()
|
||||
expect(primaryDisabled()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l5.titleSaving: Saving the title sends it with the other edits', () => {
|
||||
it('sends the title and the body in one PATCH, with no slug', async () => {
|
||||
await mountEditor({
|
||||
extra: { 'PATCH /v1/article/12': { ...articleOf({}), title: 'Couts reels' } },
|
||||
})
|
||||
await typeInto(titleInput(), 'Couts reels')
|
||||
await editBody('<p>New body</p>')
|
||||
await press('Save')
|
||||
|
||||
const calls = callsTo('PATCH', '/v1/article/12')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.body).toEqual({ title: 'Couts reels', body: '<p>New body</p>' })
|
||||
})
|
||||
|
||||
it('shows a spinner and "Saving title…" while in flight', async () => {
|
||||
const gate = deferred<unknown>()
|
||||
await mountEditor({ extra: { 'PATCH /v1/article/12': () => gate.promise } })
|
||||
await typeInto(titleInput(), 'Couts reels')
|
||||
await press('Save')
|
||||
|
||||
expect(helper()?.textContent).toContain('Saving title…')
|
||||
gate.resolve({ ...articleOf({}), title: 'Couts reels' })
|
||||
await settle()
|
||||
})
|
||||
|
||||
it('updates the breadcrumb and the menu row on 200', async () => {
|
||||
await mountEditor({
|
||||
extra: { 'PATCH /v1/article/12': { ...articleOf({}), title: 'Couts reels' } },
|
||||
})
|
||||
await typeInto(titleInput(), 'Couts reels')
|
||||
await press('Save')
|
||||
|
||||
expect(
|
||||
document.body.querySelector('nav[aria-label="breadcrumb" i]')?.textContent,
|
||||
).toContain('Couts reels')
|
||||
await openLanguageMenu()
|
||||
expect(rowFor('French')?.textContent).toContain('Couts reels')
|
||||
expect(titleInput().value).toBe('Couts reels')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l5.failed: A failed title save keeps the text', () => {
|
||||
it('keeps the text, shows the helper and a Try again action, and still reads unsaved', async () => {
|
||||
await mountEditor({ extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
|
||||
await typeInto(titleInput(), 'Couts reels')
|
||||
await press('Save')
|
||||
|
||||
expect(titleInput().value).toBe('Couts reels')
|
||||
expect(titleInput().getAttribute('aria-invalid')).toBe('true')
|
||||
expect(helper()?.textContent).toContain("Couldn't save the title. Your text is kept.")
|
||||
expect(buttonNamed('Try again')).toBeDefined()
|
||||
expect(text()).toContain('Save failed')
|
||||
})
|
||||
|
||||
it('resends the same PATCH on Try again and returns focus to the field', async () => {
|
||||
await mountEditor({ extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
|
||||
await typeInto(titleInput(), 'Couts reels')
|
||||
await press('Save')
|
||||
await press('Try again')
|
||||
|
||||
const calls = callsTo('PATCH', '/v1/article/12')
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[1]!.body).toEqual(calls[0]!.body)
|
||||
expect(document.activeElement).toBe(titleInput())
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l5.invalid: The server rejects a title the client allowed', () => {
|
||||
it('shows the plain-text message in the invalid state and keeps the edit', async () => {
|
||||
await mountEditor({ extra: { 'PATCH /v1/article/12': failWith(400, TITLE_REJECTED) } })
|
||||
await typeInto(titleInput(), 'Couts reels')
|
||||
await press('Save')
|
||||
|
||||
expect(helper()?.textContent).toContain(TITLE_REJECTED)
|
||||
expect(titleInput().getAttribute('aria-invalid')).toBe('true')
|
||||
expect(titleInput().value).toBe('Couts reels')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,214 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import router from '@/router'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import { NetworkError } from '@/composables/useApi'
|
||||
import {
|
||||
WRITE_FAILS,
|
||||
callsTo,
|
||||
deferred,
|
||||
dialogBox,
|
||||
dialogText,
|
||||
failWith,
|
||||
press,
|
||||
settle,
|
||||
text,
|
||||
toastText,
|
||||
typeInto,
|
||||
unmountRoute,
|
||||
} from './localizationKit'
|
||||
import {
|
||||
TITLES,
|
||||
articleOf,
|
||||
buttonNamed,
|
||||
mountEditor,
|
||||
openLanguageMenu,
|
||||
rowFor,
|
||||
titleInput,
|
||||
} from './editorKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const DRAFT_KEY = 'renewcms:article-draft:7:12'
|
||||
const SKELETON_DELAY_MS = 150
|
||||
|
||||
beforeEach(() => {
|
||||
initializeLocale()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
await unmountRoute()
|
||||
})
|
||||
|
||||
async function chooseEnglish() {
|
||||
await openLanguageMenu()
|
||||
rowFor('English')!.click()
|
||||
await settle()
|
||||
}
|
||||
|
||||
async function editFrenchTitle() {
|
||||
await typeInto(titleInput(), 'Couts reels')
|
||||
}
|
||||
|
||||
describe('loc.l5.menu: Switching from a clean editor', () => {
|
||||
it('requests the other version before the route changes, then shows its title', async () => {
|
||||
let routeWhenRequested = ''
|
||||
await mountEditor({
|
||||
extra: {
|
||||
'GET /v1/article/10': () => {
|
||||
routeWhenRequested = router.currentRoute.value.path
|
||||
return articleOf({ lang: 'fr' }, 'en')
|
||||
},
|
||||
},
|
||||
})
|
||||
await chooseEnglish()
|
||||
|
||||
expect(routeWhenRequested).toBe('/article/12/edit')
|
||||
expect(router.currentRoute.value.path).toBe('/article/10/edit')
|
||||
expect(titleInput().value).toBe(TITLES.en)
|
||||
expect(callsTo('GET', '/v1/article/10')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('announces "Editing English version" politely', async () => {
|
||||
await mountEditor()
|
||||
await chooseEnglish()
|
||||
|
||||
const live = document.body.querySelector('[data-testid="live-announcement"]')
|
||||
expect(live?.textContent).toBe('Editing English version')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l5.loading: A slow switch shows a loading state', () => {
|
||||
it('shows the loading version state after 150 ms and keeps the current version on screen', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const gate = deferred<unknown>()
|
||||
await mountEditor({ extra: { 'GET /v1/article/10': () => gate.promise } })
|
||||
await chooseEnglish()
|
||||
|
||||
expect(document.body.querySelector('[data-testid="skeleton-content"]')).toBeNull()
|
||||
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
|
||||
await settle()
|
||||
|
||||
expect(document.body.querySelector('[data-testid="skeleton-content"]')).not.toBeNull()
|
||||
expect(titleInput().value).toBe(TITLES.fr)
|
||||
expect(router.currentRoute.value.path).toBe('/article/12/edit')
|
||||
})
|
||||
|
||||
it('never shows a loading state when the response is faster than 150 ms', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
await mountEditor()
|
||||
await chooseEnglish()
|
||||
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS * 2)
|
||||
|
||||
expect(document.body.querySelector('[data-testid="skeleton-content"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l5.navFail: A failed switch keeps the current version', () => {
|
||||
it.each([
|
||||
['a 500', failWith(500, 'Internal server error')],
|
||||
[
|
||||
'a network failure',
|
||||
() => {
|
||||
throw new NetworkError()
|
||||
},
|
||||
],
|
||||
])('toasts and stays on French after %s', async (_name, failure) => {
|
||||
await mountEditor({ extra: { 'GET /v1/article/10': failure } })
|
||||
await chooseEnglish()
|
||||
await vi.waitFor(() => expect(toastText()).toContain("Couldn't open the English version"))
|
||||
|
||||
expect(toastText()).toContain("You're still editing French. Try again.")
|
||||
expect(router.currentRoute.value.path).toBe('/article/12/edit')
|
||||
expect(titleInput().value).toBe(TITLES.fr)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l5.gone: The editor opened directly on an unavailable version', () => {
|
||||
it('shows only the Version unavailable state with one action to Articles', async () => {
|
||||
await mountEditor({ extra: { 'GET /v1/article/12': failWith(404, 'Article not found') } })
|
||||
|
||||
expect(text()).toContain("This version isn't available")
|
||||
expect(text()).toContain('It may have been deleted, or you may not have access.')
|
||||
expect(document.body.querySelector('label')).toBeNull()
|
||||
expect(buttonNamed('Save')).toBeUndefined()
|
||||
buttonNamed('Back to Articles')!.click()
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l5.unsaved: Choosing another version with unsaved changes asks first', () => {
|
||||
async function openDialog() {
|
||||
await mountEditor()
|
||||
await editFrenchTitle()
|
||||
await chooseEnglish()
|
||||
}
|
||||
|
||||
it('asks with the exact copy and the three buttons in order', async () => {
|
||||
await openDialog()
|
||||
|
||||
expect(dialogText()).toContain('Leave without saving?')
|
||||
expect(dialogText()).toContain("Your changes to the French version aren't saved.")
|
||||
const labels = Array.from(dialogBox()!.querySelectorAll('button'))
|
||||
.map((button) => button.textContent?.trim())
|
||||
.filter((label) => label)
|
||||
expect(labels).toEqual([
|
||||
'Keep editing',
|
||||
'Discard and open English',
|
||||
'Save and open English',
|
||||
])
|
||||
})
|
||||
|
||||
it('Keep editing closes the dialog, keeps the title and sends nothing', async () => {
|
||||
await openDialog()
|
||||
await press('Keep editing', dialogBox()!)
|
||||
|
||||
expect(dialogBox()).toBeNull()
|
||||
expect(router.currentRoute.value.path).toBe('/article/12/edit')
|
||||
expect(titleInput().value).toBe('Couts reels')
|
||||
expect(callsTo('GET', '/v1/article/10')).toHaveLength(0)
|
||||
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('Discard and open English removes the local draft and saves nothing', async () => {
|
||||
sessionStorage.setItem(
|
||||
DRAFT_KEY,
|
||||
JSON.stringify({ title: 'Kept', mirrored_at: '2026-10-14T14:18:00Z' }),
|
||||
)
|
||||
await mountEditor()
|
||||
await chooseEnglish()
|
||||
await press('Discard and open English', dialogBox()!)
|
||||
|
||||
expect(sessionStorage.getItem(DRAFT_KEY)).toBeNull()
|
||||
expect(router.currentRoute.value.path).toBe('/article/10/edit')
|
||||
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('Save and open English saves first, then opens the version', async () => {
|
||||
await mountEditor({
|
||||
extra: { 'PATCH /v1/article/12': { ...articleOf({}), title: 'Couts reels' } },
|
||||
})
|
||||
await editFrenchTitle()
|
||||
await chooseEnglish()
|
||||
await press('Save and open English', dialogBox()!)
|
||||
|
||||
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(1)
|
||||
expect(router.currentRoute.value.path).toBe('/article/10/edit')
|
||||
})
|
||||
|
||||
it('stops when the save fails: no request for article 10 and Save failed shows', async () => {
|
||||
await mountEditor({ extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
|
||||
await editFrenchTitle()
|
||||
await chooseEnglish()
|
||||
await press('Save and open English', dialogBox()!)
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/article/12/edit')
|
||||
expect(callsTo('GET', '/v1/article/10')).toHaveLength(0)
|
||||
expect(text()).toContain('Save failed')
|
||||
expect(titleInput().value).toBe('Couts reels')
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import RootComponent from '@/App.vue'
|
||||
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
|
||||
@@ -98,7 +99,7 @@ async function mountEditor(
|
||||
overlayMock = createOverlayMock()
|
||||
toastAdd = vi.fn()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.session = { id: 7, verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
|
||||
await router.push(path)
|
||||
@@ -135,6 +136,7 @@ function paragraphOfLength(totalLength: number) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(FIXED_NOW))
|
||||
})
|
||||
@@ -194,7 +196,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
|
||||
describe('Saved — a freshly loaded article, draft or online', () => {
|
||||
it.each([
|
||||
{ is_online: false, badge: 'Draft', publishLabel: 'Publish' },
|
||||
{ is_online: true, badge: 'Online', publishLabel: 'Unpublish' },
|
||||
{ is_online: true, badge: 'Published', publishLabel: 'Unpublish' },
|
||||
])(
|
||||
'reads the Saved state for is_online $is_online',
|
||||
async ({ is_online, badge, publishLabel }) => {
|
||||
@@ -251,7 +253,8 @@ describe('Saving — Save sends the PATCH and disables itself while in flight',
|
||||
body: paragraphOfLength(4320),
|
||||
})
|
||||
expect(wrapper.text()).toContain('Saving…')
|
||||
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(saveButton(wrapper).exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="primary-action"]').attributes('aria-busy')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -328,14 +331,14 @@ describe('SaveStatus switches to a relative time after an hour', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Save failed — a 500 or a network failure keeps the changes and offers Retry', () => {
|
||||
describe('Save failed — a 500 or a network failure keeps the changes and offers Try saving again', () => {
|
||||
it.each([
|
||||
{ name: 'a 500 response', handler: () => mockResponse(500) },
|
||||
{
|
||||
name: 'a network failure',
|
||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
},
|
||||
])('shows Save failed with Retry for $name', async ({ handler }) => {
|
||||
])('shows Save failed with Try saving again for $name', async ({ handler }) => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
|
||||
'PATCH /v1/article/12?format=html': handler,
|
||||
@@ -345,12 +348,14 @@ describe('Save failed — a 500 or a network failure keeps the changes and offer
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Save failed')
|
||||
expect(wrapper.findAll('button').some((button) => button.text() === 'Retry')).toBe(true)
|
||||
expect(
|
||||
wrapper.findAll('button').some((button) => button.text() === 'Try saving again'),
|
||||
).toBe(true)
|
||||
expect(articleEditorBody(wrapper)).toBe(paragraph('Revised opening paragraph.'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Retry resends the same body and can recover', () => {
|
||||
describe('Try saving again resends the same body and can recover', () => {
|
||||
it('resends the same PATCH and shows Saved at the current time', async () => {
|
||||
const patchCall = vi
|
||||
.fn()
|
||||
@@ -371,7 +376,9 @@ describe('Retry resends the same body and can recover', () => {
|
||||
await saveButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const retryButton = wrapper.findAll('button').find((button) => button.text() === 'Retry')
|
||||
const retryButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'Try saving again')
|
||||
await retryButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -448,7 +455,7 @@ describe('Very long article — no length limit', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Saving 120,000 characters. This can take a few seconds.')
|
||||
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(saveButton(wrapper).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reads Saved once the PATCH resolves', async () => {
|
||||
@@ -601,6 +608,29 @@ describe('Esc or the overlay on the leave guard behaves like "Keep editing"', ()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Leaving after a failed save still prompts', () => {
|
||||
it('opens the leave dialog while Save failed is showing', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
|
||||
'PATCH /v1/article/12?format=html': () => mockResponse(500),
|
||||
})
|
||||
await typeBody(wrapper, paragraph('Revised opening paragraph.'))
|
||||
await saveButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Save failed')
|
||||
|
||||
const navigation = router.push('/article')
|
||||
await flushPromises()
|
||||
|
||||
expect((overlayMock.getProps() as Record<string, unknown>).title).toBe(
|
||||
'Leave without saving?',
|
||||
)
|
||||
overlayMock.resolveOpen(true)
|
||||
await navigation
|
||||
expect(router.currentRoute.value.path).toBe('/article/12/edit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Leaving a clean article never prompts', () => {
|
||||
it('navigates directly with no ConfirmDialog', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
@@ -627,13 +657,15 @@ describe('Session expired mid-save keeps the draft and restores it after logging
|
||||
await saveButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBe(body)
|
||||
expect(JSON.parse(sessionStorage.getItem('renewcms:article-draft:7:12') ?? '{}').body).toBe(
|
||||
body,
|
||||
)
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
|
||||
expect(router.currentRoute.value.query.reason).toBe('expired')
|
||||
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.session = { id: 7, verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter({
|
||||
'GET /v1/article/12?format=html': () =>
|
||||
@@ -797,13 +829,13 @@ describe('An unknown or deleted article id replaces the editor with an error sta
|
||||
'/article/9999/edit',
|
||||
)
|
||||
|
||||
expect(wrapper.text()).toContain("This article doesn't exist.")
|
||||
expect(wrapper.text()).toContain("This version isn't available")
|
||||
expect(wrapper.find('[data-testid="save-button"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('Publish')
|
||||
|
||||
const allArticlesButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'All articles')
|
||||
.find((button) => button.text() === 'Back to Articles')
|
||||
await allArticlesButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ describe('Dismiss x: behaviour', () => {
|
||||
await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
expect(textOf(document.body)).toContain("This article doesn't exist.")
|
||||
expect(textOf(document.body)).toContain("This version isn't available")
|
||||
})
|
||||
|
||||
it('shows nothing and sends no check on a 401', async () => {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import RootComponent from '@/App.vue'
|
||||
import { DESKTOP_WIDTH } from '@/components/__tests__/viewport'
|
||||
import type { ArticleCapability } from '@/composables/useArticles'
|
||||
import { storyEnvelope } from './articleEnvelope'
|
||||
import { languagesBody, type LanguageFixture } from './localizationData'
|
||||
import { kit, mountRoute, settle, text, type Route } from './localizationKit'
|
||||
|
||||
export const FULL: ArticleCapability[] = ['read', 'edit', 'publish', 'delete', 'create_translation']
|
||||
export const READ: ArticleCapability[] = ['read']
|
||||
export const EDIT_ONLY: ArticleCapability[] = ['read', 'edit']
|
||||
export const PUBLISH_ONLY: ArticleCapability[] = ['read', 'publish']
|
||||
|
||||
export type Lang = 'en' | 'fr' | 'ar' | 'de'
|
||||
|
||||
export const IDS: Record<Lang, number> = { en: 10, fr: 12, ar: 13, de: 11 }
|
||||
export const TITLES: Record<Lang, string> = {
|
||||
en: 'Renovation costs',
|
||||
fr: 'Couts de renovation',
|
||||
ar: 'Takalif',
|
||||
de: 'Renovierungskosten',
|
||||
}
|
||||
const ONLINE: Record<Lang, boolean> = { en: true, fr: false, ar: true, de: false }
|
||||
const UPDATED_AT = '2026-09-26T11:02:00Z'
|
||||
const TRANSLATION_ORDER: Lang[] = ['en', 'ar', 'fr']
|
||||
|
||||
export interface EditorFixture {
|
||||
lang?: Lang
|
||||
caps?: ArticleCapability[]
|
||||
siblingCaps?: Partial<Record<Lang, ArticleCapability[]>>
|
||||
online?: Partial<Record<Lang, boolean>>
|
||||
languageEnabled?: Partial<Record<Lang, boolean>>
|
||||
translations?: Lang[]
|
||||
body?: string
|
||||
languages?: LanguageFixture[]
|
||||
extra?: Record<string, Route>
|
||||
userId?: number
|
||||
width?: number
|
||||
}
|
||||
|
||||
const JOURNAL_LANGUAGES: LanguageFixture[] = [
|
||||
{ lang: 'en', versions: 7, published: 6 },
|
||||
{ lang: 'fr', versions: 4, published: 2 },
|
||||
{ lang: 'de', versions: 4, published: 3 },
|
||||
{ lang: 'ar', versions: 1, published: 0 },
|
||||
{ lang: 'es', enabled: false, versions: 1, published: 1 },
|
||||
]
|
||||
|
||||
const SITE = {
|
||||
id: 1,
|
||||
name: 'Journal',
|
||||
key: 'journal',
|
||||
site_name: 'Journal',
|
||||
title_template: '%title% | %site%',
|
||||
default_description: '',
|
||||
base_url: 'https://journal.example.com',
|
||||
default_social_image_id: null,
|
||||
default_robots: 'index',
|
||||
locale: 'en',
|
||||
is_default: true,
|
||||
article_count: 4,
|
||||
public_url_pattern: null,
|
||||
}
|
||||
|
||||
function capsOf(fixture: EditorFixture, lang: Lang) {
|
||||
const current = fixture.lang ?? 'fr'
|
||||
return lang === current ? (fixture.caps ?? FULL) : (fixture.siblingCaps?.[lang] ?? FULL)
|
||||
}
|
||||
|
||||
function entryOf(fixture: EditorFixture, lang: Lang) {
|
||||
return {
|
||||
id: IDS[lang],
|
||||
lang,
|
||||
title: TITLES[lang],
|
||||
is_online: fixture.online?.[lang] ?? ONLINE[lang],
|
||||
language_enabled: fixture.languageEnabled?.[lang] ?? true,
|
||||
capabilities: capsOf(fixture, lang),
|
||||
}
|
||||
}
|
||||
|
||||
export function articleOf(fixture: EditorFixture, lang: Lang = fixture.lang ?? 'fr') {
|
||||
const order = fixture.translations ?? TRANSLATION_ORDER
|
||||
return {
|
||||
id: IDS[lang],
|
||||
title: TITLES[lang],
|
||||
body: fixture.body ?? `<p>Body of ${TITLES[lang]}</p>`,
|
||||
images: [],
|
||||
is_online: fixture.online?.[lang] ?? ONLINE[lang],
|
||||
lang,
|
||||
story_id: 5,
|
||||
capabilities: capsOf(fixture, lang),
|
||||
translations: order.map((tag) => entryOf(fixture, tag)),
|
||||
site_id: 1,
|
||||
authors: [],
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: UPDATED_AT,
|
||||
}
|
||||
}
|
||||
|
||||
export function editorRoutes(fixture: EditorFixture = {}): Record<string, Route> {
|
||||
const current = fixture.lang ?? 'fr'
|
||||
const ready = { status: 'ready', suggestions: [], slug: { available: true, suggestion: null } }
|
||||
const routes: Record<string, Route> = {
|
||||
'GET /v1/site/': [SITE],
|
||||
'GET /v1/site/journal/languages': languagesBody(
|
||||
'journal-1',
|
||||
'en',
|
||||
fixture.languages ?? JOURNAL_LANGUAGES,
|
||||
),
|
||||
'GET /v1/author/': { items: [], total: 0, page: 1, size: 100 },
|
||||
'GET /v1/article/': (call) =>
|
||||
storyEnvelope([], { size: Number(call.query.get('size')) || 25 }),
|
||||
'GET /v1/media/': { items: [], next_cursor: null, counts: { all: 0 } },
|
||||
}
|
||||
for (const lang of Object.keys(IDS) as Lang[]) {
|
||||
routes[`GET /v1/article/${IDS[lang]}`] = articleOf(fixture, lang)
|
||||
routes[`POST /v1/article/${IDS[lang]}/seo/check`] = ready
|
||||
}
|
||||
routes[`GET /v1/article/${IDS[current]}`] = articleOf(fixture, current)
|
||||
return { ...routes, ...fixture.extra }
|
||||
}
|
||||
|
||||
export async function mountEditor(fixture: EditorFixture = {}, path?: string) {
|
||||
const lang = fixture.lang ?? 'fr'
|
||||
await mountRoute(path ?? `/article/${IDS[lang]}/edit`, {
|
||||
routes: editorRoutes(fixture),
|
||||
width: fixture.width ?? DESKTOP_WIDTH,
|
||||
userId: fixture.userId ?? 7,
|
||||
root: RootComponent,
|
||||
})
|
||||
await settle()
|
||||
}
|
||||
|
||||
export function titleInput() {
|
||||
const label = Array.from(document.body.querySelectorAll('label')).find((element) =>
|
||||
/^Title/.test(element.textContent ?? ''),
|
||||
)
|
||||
return document.getElementById(label?.getAttribute('for') ?? '') as HTMLTextAreaElement
|
||||
}
|
||||
|
||||
export function languageTrigger() {
|
||||
return document.body.querySelector<HTMLElement>('[aria-label$="Switch version"]')
|
||||
}
|
||||
|
||||
export function pointerDown(target: Element | null) {
|
||||
target?.dispatchEvent(
|
||||
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
|
||||
pointerType: 'mouse',
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function openLanguageMenu() {
|
||||
pointerDown(languageTrigger())
|
||||
languageTrigger()!.click()
|
||||
await settle()
|
||||
}
|
||||
|
||||
export async function openMoreActions() {
|
||||
const trigger = document.body.querySelector<HTMLElement>('[aria-label="More actions"]')
|
||||
pointerDown(trigger)
|
||||
trigger!.click()
|
||||
await settle()
|
||||
}
|
||||
|
||||
export function menuRows() {
|
||||
return Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
|
||||
)
|
||||
}
|
||||
|
||||
export function rowFor(name: string) {
|
||||
return menuRows().find((row) => row.textContent?.trim().startsWith(name))
|
||||
}
|
||||
|
||||
export function pageText() {
|
||||
return text()
|
||||
}
|
||||
|
||||
export function buttonNamed(label: string | RegExp) {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('button')).find((element) => {
|
||||
const name = (element.getAttribute('aria-label') || element.textContent || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return typeof label === 'string' ? name === label : label.test(name)
|
||||
})
|
||||
}
|
||||
|
||||
export async function editBody(html = '<p>New body</p>') {
|
||||
kit.wrapper!.findComponent({ name: 'ArticleEditor' }).vm.$emit('update:modelValue', html)
|
||||
await settle()
|
||||
}
|
||||
|
||||
export function toolbar() {
|
||||
return document.body.querySelector('.article-toolbar')
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Byline } from '@/composables/useArticles'
|
||||
import type { ArticleCapability, Byline } from '@/composables/useArticles'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
|
||||
export const defaultSite: Site = {
|
||||
@@ -59,7 +59,19 @@ export const camille: Byline = {
|
||||
}
|
||||
export const paul: Byline = { id: 8, name: 'Paul Huber', slug: 'paul-huber' }
|
||||
|
||||
export const articleMeta = { site_id: defaultSite.id, authors: [ines] }
|
||||
const FULL_CAPABILITIES: ArticleCapability[] = [
|
||||
'read',
|
||||
'edit',
|
||||
'publish',
|
||||
'delete',
|
||||
'create_translation',
|
||||
]
|
||||
|
||||
export const articleMeta = {
|
||||
site_id: defaultSite.id,
|
||||
authors: [ines],
|
||||
capabilities: FULL_CAPABILITIES,
|
||||
}
|
||||
|
||||
export const readyCheck = {
|
||||
status: 'ready',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { defineComponent, h, type Component } from 'vue'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { RouterView } from 'vue-router'
|
||||
@@ -46,6 +46,8 @@ interface MountOptions {
|
||||
role?: string
|
||||
routes: Record<string, Route>
|
||||
width?: number
|
||||
userId?: number
|
||||
root?: Component
|
||||
}
|
||||
|
||||
export async function mountRoute(url: string, options: MountOptions) {
|
||||
@@ -57,7 +59,7 @@ export async function mountRoute(url: string, options: MountOptions) {
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
id: options.userId ?? 1,
|
||||
email: 'marta@renew.example',
|
||||
displayName: 'Marta Alves',
|
||||
access: accessFor(options.role ?? 'owner'),
|
||||
@@ -85,10 +87,10 @@ export async function mountRoute(url: string, options: MountOptions) {
|
||||
requestPaged: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
await router.push(url)
|
||||
kit.wrapper = mount(
|
||||
defineComponent({ render: () => h(UApp, null, { default: () => h(RouterView) }) }),
|
||||
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
|
||||
)
|
||||
const root =
|
||||
options.root ??
|
||||
defineComponent({ render: () => h(UApp, null, { default: () => h(RouterView) }) })
|
||||
kit.wrapper = mount(root, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
|
||||
await settle()
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ export function articleOf(id: number, title: string, overrides: Partial<Article>
|
||||
title,
|
||||
body: '<p>Intro</p>',
|
||||
is_online: false,
|
||||
capabilities: ['read', 'edit', 'publish', 'delete', 'create_translation'],
|
||||
site_id: 1,
|
||||
authors: bylines('Inès Duarte'),
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
|
||||
Reference in new issue
Block a user