feat: translation flows, revision conflicts, delete scopes, article moves and a target site for new articles

This commit is contained in:
Florian Sylvain committed 2026-11-03 00:12:29 +01:00
1 parent 339e646eb8
commit 45fbbc33b1
93 files changed
+7630 -294

No files matched your search

+345
View File
@@ -0,0 +1,345 @@
<script setup lang="ts">
import { locale, t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import DirectoryField from '@/components/DirectoryField.vue'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleChoiceCards from '@/components/localization/LocaleChoiceCards.vue'
import LocaleDialog from '@/components/localization/LocaleDialog.vue'
import LocaleInput from '@/components/localization/LocaleInput.vue'
import LocaleSelect from '@/components/localization/LocaleSelect.vue'
import TranslationFailureAlert from '@/components/localization/TranslationFailure.vue'
import { useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import { existingArticleOf, classifyWithLanguages } from '@/lib/translationFailure'
import type { TranslationFailure } from '@/lib/translationFailure'
import { titleVerdict } from '@/lib/editorTitle'
import { languageName, type SiteLanguage } from '@/lib/siteLanguages'
import {
authorsNote,
defaultSource,
imagesNote,
offeredLanguages,
sourceLabel,
} from '@/lib/translationSource'
export type AddTranslationResult =
| { kind: 'created'; article: Article; lang: string }
| { kind: 'open'; id: number; lang: string }
| { kind: 'gone' }
const props = defineProps<{
article: Article
languages: SiteLanguage[]
initialLang?: string
refetchLanguages: () => Promise<SiteLanguage[]>
}>()
const emit = defineEmits<{ close: [result: AddTranslationResult | undefined] }>()
type Mode = 'copy' | 'empty'
type Shown = Exclude<TranslationFailure, 'gone' | 'session'>
const BLOCKING: Shown[] = ['exists', 'hidden', 'langOff', 'unreadable']
const NOTE_FETCH_FAILED = null
const { request } = useApi()
const readable = computed(() => props.article.translations ?? [])
const languagesNow = ref(props.languages)
const offered = computed(() => offeredLanguages(languagesNow.value, readable.value))
const lang = ref<string | undefined>(
props.initialLang && offered.value.includes(props.initialLang) ? props.initialLang : undefined,
)
const mode = ref<Mode>('copy')
const initialSource = defaultSource(languagesNow.value, readable.value)
const sourceId = ref<string | undefined>(initialSource ? String(initialSource.id) : undefined)
const title = ref('')
const titleError = ref('')
const pending = ref(false)
const failure = ref<Shown | null>(null)
const existingId = ref<number | undefined>(undefined)
const sourceDetails = ref<Article | null>(null)
const titleFieldWrap = ref<HTMLElement | null>(null)
const detailsCache = new Map<number, Article | null>()
const targetName = computed(() => (lang.value ? languageName(lang.value, locale.value) : ''))
const source = computed(() => readable.value.find((entry) => String(entry.id) === sourceId.value))
const lastSourceName = ref('')
const sourceName = computed(() =>
source.value ? languageName(source.value.lang, locale.value) : lastSourceName.value,
)
watch(
source,
(entry) => {
if (entry) {
lastSourceName.value = languageName(entry.lang, locale.value)
}
},
{ immediate: true },
)
const langItems = computed(() => {
const tags =
lang.value && !offered.value.includes(lang.value)
? [...offered.value, lang.value]
: offered.value
return tags.map((tag) => ({ label: languageName(tag, locale.value), value: tag }))
})
const sourceItems = computed(() =>
readable.value.map((entry) => ({ label: sourceLabel(entry), value: String(entry.id) })),
)
const choiceItems = computed(() => [
{
value: 'empty',
label: t('Start empty'),
description: t(
'Only the title. Freshness shows No source until you pick a tracked source.',
),
},
{
value: 'copy',
label: t('Copy from {0}', [sourceName.value]),
description: t(
"Copies body, excerpt, search and social text, image alternative text and authors. Images are reused, files aren't duplicated. The version starts as Needs review.",
),
},
])
const showsDescription = computed(() => failure.value === null && !pending.value)
const description = computed(() =>
!showsDescription.value
? undefined
: lang.value
? t('Creates a {0} draft of "{1}". It isn\'t published.', [
targetName.value,
props.article.title,
])
: t('Creates a draft of "{0}" in the language you choose. It isn\'t published.', [
props.article.title,
]),
)
const titleLabel = computed(() => (lang.value ? t('Title in {0}', [targetName.value]) : t('Title')))
const titleHelp = computed(() =>
mode.value === 'copy' && sourceName.value
? t('Copying text is a starting point: it stays in {0} until you translate it.', [
sourceName.value,
])
: '',
)
const confirmLabel = computed(() => {
if (pending.value) {
return t('Creating…')
}
if (failure.value === 'failed') {
return t('Try again')
}
return lang.value ? t('Create {0} draft', [targetName.value]) : t('Create draft')
})
const blocked = computed(
() => lang.value === undefined || (failure.value !== null && BLOCKING.includes(failure.value)),
)
const sourceMissing = computed(() => mode.value === 'copy' && sourceId.value === undefined)
const authors = computed(() =>
mode.value === 'copy' ? authorsNote(sourceDetails.value?.authors ?? []) : null,
)
const images = computed(() =>
mode.value === 'copy' && sourceDetails.value
? imagesNote(sourceDetails.value, sourceName.value)
: null,
)
const placement = computed(() => {
if (!showsDescription.value) {
return 'sm:top-25 sm:max-h-169 sm:max-w-115'
}
return authors.value || images.value
? 'sm:top-6 sm:max-h-207 sm:max-w-115'
: 'sm:top-15 sm:max-h-169 sm:max-w-115'
})
const languageReadOnlyError = computed(() =>
failure.value === 'langOff' ? t('{0} was disabled on this site.', [targetName.value]) : '',
)
const openOptions = {
onOpenAutoFocus: (event: Event) => {
event.preventDefault()
nextTick(() => {
const field = lang.value
? titleFieldWrap.value?.querySelector<HTMLElement>('input')
: document.querySelector<HTMLElement>('[role="combobox"]')
field?.focus()
})
},
}
async function loadDetails() {
const id = source.value?.id
if (mode.value !== 'copy' || id === undefined) {
sourceDetails.value = null
return
}
if (id === props.article.id) {
sourceDetails.value = props.article
return
}
if (!detailsCache.has(id)) {
detailsCache.set(
id,
await request<Article>(`/v1/article/${id}?format=html`).catch(() => NOTE_FETCH_FAILED),
)
}
sourceDetails.value = detailsCache.get(id) ?? null
}
watch([sourceId, mode], loadDetails, { immediate: true })
watch(lang, () => {
if (failure.value && failure.value !== 'unreadable' && failure.value !== 'failed') {
failure.value = null
}
titleError.value = ''
})
watch([sourceId, mode], () => {
if (
failure.value === 'unreadable' &&
(mode.value === 'empty' || sourceId.value !== undefined)
) {
failure.value = null
}
})
function bodyOf(): Record<string, unknown> {
const base = { lang: lang.value, title: title.value }
return mode.value === 'copy'
? { ...base, copy: true, source_article_id: Number(sourceId.value) }
: { ...base, copy: false }
}
async function reloadLanguages() {
languagesNow.value = await props.refetchLanguages()
return languagesNow.value
}
async function submit() {
const verdict = titleVerdict(title.value)
if (verdict !== 'ok') {
titleError.value = {
empty: t('Enter a title'),
short: t('Use at least 3 characters'),
long: t('Use 200 characters or fewer'),
}[verdict]
return
}
titleError.value = ''
failure.value = null
pending.value = true
try {
const created = await request<Article>(
`/v1/article/${props.article.id}/translations?format=html`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bodyOf()),
onMissingPermission: 'passthrough',
},
)
emit('close', { kind: 'created', article: created, lang: lang.value! })
} catch (error) {
const kind = await classifyWithLanguages(error, lang.value, reloadLanguages)
if (kind === 'gone') {
emit('close', { kind: 'gone' })
} else if (kind !== 'session') {
failure.value = kind
existingId.value = existingArticleOf(error)
if (kind === 'unreadable') {
sourceId.value = undefined
}
}
} finally {
pending.value = false
}
}
function openExisting() {
if (existingId.value !== undefined && lang.value) {
emit('close', { kind: 'open', id: existingId.value, lang: lang.value })
}
}
</script>
<template>
<LocaleDialog
:title="t('Add a translation')"
:description="description"
:confirm-label="confirmLabel"
:pending="pending"
:confirm-disabled="blocked || sourceMissing"
:placement="placement"
:content="openOptions"
@close="emit('close', undefined)"
@confirm="submit"
>
<TranslationFailureAlert
v-if="failure"
:kind="failure"
:language="targetName"
:can-open="existingId !== undefined"
@open="openExisting"
/>
<DirectoryField :label="t('Language')" :error="languageReadOnlyError">
<LocaleSelect
v-model="lang"
:items="langItems"
:aria-label="t('Language')"
:readonly="pending || failure === 'langOff'"
:shaded="failure === 'langOff'"
:invalid="failure === 'langOff'"
/>
</DirectoryField>
<DirectoryField
v-if="mode === 'copy'"
:label="t('Source version')"
:help="
t(
'You choose which version to copy from. It is also the source tracked for review. Access is checked again when you create the draft.',
)
"
>
<LocaleSelect
v-model="sourceId"
:items="sourceItems"
:placeholder="t('Choose a version')"
:aria-label="t('Source version')"
:readonly="pending"
/>
</DirectoryField>
<div ref="titleFieldWrap">
<DirectoryField :label="titleLabel" :error="titleError" :help="titleHelp">
<LocaleInput v-model="title" :invalid="!!titleError" :readonly="pending" flat />
</DirectoryField>
</div>
<div class="flex flex-col gap-2">
<span class="font-medium text-highlighted">{{ t('Starting content') }}</span>
<LocaleChoiceCards
:model-value="mode"
@update:model-value="mode = $event as Mode"
:items="choiceItems"
:legend="t('Starting content')"
:disabled="pending"
/>
</div>
<LocaleAlert
v-if="authors"
tone="neutral"
icon="i-lucide-info"
:title="t('Authors')"
:description="authors"
/>
<LocaleAlert
v-if="images"
tone="neutral"
icon="i-lucide-info"
:title="t('Images')"
:description="images"
/>
</LocaleDialog>
</template>
+255
View File
@@ -0,0 +1,255 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import { SHEET_CONTENT_CLASS } from '@/components/sheetClasses'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleButton from '@/components/localization/LocaleButton.vue'
import type { Article } from '@/composables/useArticles'
import type { SaveOutcome } from '@/composables/useArticleDraft'
import { clockOf } from '@/lib/clockTime'
export type ConflictResult = 'keep' | 'saved' | 'reloaded' | 'gone' | 'unauthorized' | 'forbidden'
const props = defineProps<{
theirs: Article
restoredAt: Date | null
fetchTheirs: () => Promise<Article | null>
attemptSave: (revision: number | undefined) => Promise<{ outcome: SaveOutcome; status: number }>
download: () => void
applyTheirs: (theirs: Article) => void
}>()
const emit = defineEmits<{ close: [result: ConflictResult] }>()
type Step = 'choose' | 'confirmReload'
type Failure = 'again' | 'failed'
const CLOSE_BUTTON = {
color: 'neutral',
variant: 'ghost',
class: 'size-11 justify-center p-0 text-muted md:size-7',
} as const
const FORBIDDEN_STATUS = 403
const shown = ref(props.theirs)
const step = ref<Step>('choose')
const failure = ref<Failure | null>(null)
const pending = ref(false)
const keepButton = ref<{ $el: HTMLElement } | null>(null)
const time = computed(() => clockOf(new Date(shown.value.updated_at)))
const title = computed(() =>
step.value === 'choose' ? t('Someone else saved this version') : t('Reload theirs?'),
)
const description = computed(() =>
step.value === 'choose'
? t(
"Someone else saved this version at {0}. Your changes aren't saved. They stay here until you choose.",
[time.value],
)
: t('This discards your unsaved changes.'),
)
const failureAlert = computed(() => {
if (failure.value === 'again') {
return {
title: t("Couldn't replace theirs"),
description: t(
'Someone else saved this version again at {0}, before your save went through. Nothing was overwritten. Your changes are still here.',
[time.value],
),
}
}
if (failure.value === 'failed') {
return {
title: t("Couldn't replace theirs"),
description: t('Nothing was overwritten. Your changes are still here. Try again.'),
}
}
return null
})
const openOptions = {
onOpenAutoFocus: (event: Event) => {
event.preventDefault()
nextTick(() => keepButton.value?.$el?.focus())
},
onCloseAutoFocus: (event: Event) => event.preventDefault(),
}
async function saveOver() {
pending.value = true
failure.value = null
try {
const { outcome, status } = await props.attemptSave(shown.value.revision)
if (outcome === 'saved') {
emit('close', 'saved')
} else if (outcome === 'conflict') {
const newer = await props.fetchTheirs()
if (newer) {
shown.value = newer
}
failure.value = newer ? 'again' : 'failed'
} else if (outcome === 'not-found') {
emit('close', 'gone')
} else if (outcome === 'unauthorized') {
emit('close', 'unauthorized')
} else if (status === FORBIDDEN_STATUS) {
emit('close', 'forbidden')
} else {
failure.value = 'failed'
}
} finally {
pending.value = false
}
}
function reload() {
props.applyTheirs(shown.value)
emit('close', 'reloaded')
}
</script>
<template>
<UModal
:open="true"
:title="title"
:description="description"
:dismissible="!pending"
:close="pending ? false : CLOSE_BUTTON"
:content="openOptions"
:ui="{
content: `${SHEET_CONTENT_CLASS} sm:top-15 sm:max-w-140`,
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: 'px-6 pt-4 pb-10 sm:px-6 sm:pt-4 sm:pb-10',
}"
@update:open="(value: boolean) => !value && !pending && emit('close', 'keep')"
>
<template #body>
<div class="flex flex-col gap-3.5">
<LocaleAlert
v-if="failureAlert"
tone="error"
icon="i-lucide-circle-x"
:title="failureAlert.title"
:description="failureAlert.description"
/>
<LocaleAlert
v-else-if="restoredAt && step === 'choose'"
tone="info"
icon="i-lucide-info"
:title="t('Restored unsaved changes from {0}', [clockOf(restoredAt)])"
:description="t('Your session ended. These changes were kept in this browser.')"
/>
<div class="flex flex-col gap-3.5">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="font-semibold text-highlighted">{{
t('Keep my changes')
}}</span>
<span class="text-sm/4.75 text-pretty text-muted">{{
t('Nothing is sent. Your changes stay in the editor.')
}}</span>
</div>
<div class="flex sm:w-50 sm:shrink-0">
<LocaleButton
ref="keepButton"
kind="primary"
block
:disabled="pending"
@click="emit('close', 'keep')"
>
{{ t('Keep my changes') }}
</LocaleButton>
</div>
</div>
<template v-if="step === 'choose'">
<div
class="flex flex-col gap-2 border-t border-default pt-3.5 sm:flex-row sm:items-center sm:gap-4"
>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="font-semibold text-highlighted">{{
t('Download my version')
}}</span>
<span class="text-sm/4.75 text-pretty text-muted">{{
t('Saves your title and body as a text file. Nothing is sent.')
}}</span>
</div>
<div class="flex sm:w-50 sm:shrink-0">
<LocaleButton block :disabled="pending" @click="download()">
{{ t('Download my version') }}
</LocaleButton>
</div>
</div>
<div
class="flex flex-col gap-2 border-t border-default pt-3.5 sm:flex-row sm:items-center sm:gap-4"
>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="font-semibold text-highlighted">{{
t('Reload theirs')
}}</span>
<span class="text-sm/4.75 text-pretty text-muted">{{
t('Shows their version. You confirm first.')
}}</span>
</div>
<div class="flex sm:w-50 sm:shrink-0">
<LocaleButton
block
:disabled="pending"
@click="step = 'confirmReload'"
>
{{ t('Reload theirs') }}
</LocaleButton>
</div>
</div>
<div
class="flex flex-col gap-2 border-t border-default pt-3.5 sm:flex-row sm:items-center sm:gap-4"
>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="font-semibold text-highlighted">{{
t('Save over theirs')
}}</span>
<span class="text-sm/4.75 text-pretty text-muted">{{
t(
'Replaces the title, body and search fields of this version only. Other languages are untouched.',
)
}}</span>
</div>
<div class="flex sm:w-50 sm:shrink-0">
<LocaleButton
block
:disabled="pending"
:loading="pending"
@click="saveOver"
>
{{ t('Save over theirs') }}
</LocaleButton>
</div>
</div>
</template>
<div
v-else
class="flex flex-col gap-2 border-t border-default pt-3.5 sm:flex-row sm:items-center sm:gap-4"
>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="font-semibold text-highlighted">{{
t('Reload theirs')
}}</span>
<span class="text-sm/4.75 text-pretty text-muted">{{
t('This discards your unsaved changes.')
}}</span>
</div>
<div class="flex sm:w-50 sm:shrink-0">
<LocaleButton block @click="reload">
{{ t('Reload and discard my changes') }}
</LocaleButton>
</div>
</div>
</div>
</div>
</template>
</UModal>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref } from 'vue'
import LocaleDialog from '@/components/localization/LocaleDialog.vue'
export type TaskResult =
| { done: true; value?: unknown }
| { done: false; error: { title: string; description?: string } }
const props = defineProps<{
title: string
description: string
confirmLabel: string
cancelLabel?: string
tone?: 'primary' | 'danger'
run: () => Promise<TaskResult>
}>()
const emit = defineEmits<{ close: [value: unknown] }>()
const pending = ref(false)
const error = ref<{ title: string; description?: string } | null>(null)
async function confirm() {
pending.value = true
error.value = null
try {
const result = await props.run()
if (result.done) {
emit('close', result.value ?? true)
} else {
error.value = result.error
}
} finally {
pending.value = false
}
}
</script>
<template>
<LocaleDialog
:title="title"
:description="description"
:confirm-label="confirmLabel"
:cancel-label="cancelLabel"
:tone="tone"
:pending="pending"
:error="error"
@close="emit('close', undefined)"
@confirm="confirm"
/>
</template>
+5 -5
View File
@@ -6,7 +6,7 @@ import AuthorList from '@/components/AuthorList.vue'
import AuthorSelect from '@/components/AuthorSelect.vue'
import CreateAuthorDialog from '@/components/CreateAuthorDialog.vue'
import CreateAuthorHint from '@/components/CreateAuthorHint.vue'
import SiteSelect from '@/components/SiteSelect.vue'
import SiteMoveField from '@/components/SiteMoveField.vue'
import type { Byline } from '@/composables/useArticles'
import type { Author } from '@/composables/useAuthors'
import { useMyAuthorProfile } from '@/composables/useMyAuthorProfile'
@@ -28,7 +28,7 @@ const props = withDefaults(
)
const emit = defineEmits<{
'update:siteId': [siteId: number]
move: []
'update:authors': [authors: Byline[]]
'update:remembered': [open: boolean]
}>()
@@ -116,11 +116,11 @@ function onCreated(author: Author) {
@create="createMine"
@dismiss="dismissHint"
/>
<SiteSelect
<SiteMoveField
v-if="severalSites"
:sites="sites"
:model-value="siteId"
@update:model-value="(id) => id !== null && emit('update:siteId', id)"
:site-id="siteId"
@move="emit('move')"
/>
<AuthorList
:authors="authors"
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleDialog from '@/components/localization/LocaleDialog.vue'
import { joinNames } from '@/lib/nameList'
import type { MoveResult, MoveVersion } from '@/lib/moveArticle'
const props = defineProps<{
destination: string
origin: string
versions: MoveVersion[]
run: () => Promise<MoveResult>
}>()
const emit = defineEmits<{ close: [moved: boolean | 'gone'] }>()
type Stage = 'confirm' | 'moving' | Exclude<MoveResult['kind'], 'moved' | 'gone'>
const stage = ref<Stage>('confirm')
const result = ref<MoveResult | null>(null)
const moving = computed(() => stage.value === 'moving')
const refused = computed(() => !['confirm', 'moving'].includes(stage.value))
const versionCount = computed(() => props.versions.length)
const title = computed(() => {
if (stage.value === 'perm') {
return t("You can't move this article")
}
return moving.value
? t('Moving to {0}…', [props.destination])
: t('Move to {0}?', [props.destination])
})
const description = computed(() => {
if (stage.value === 'perm') {
return undefined
}
if (moving.value) {
return versionCount.value > 1
? t('All {0} versions move together. Keep this page open.', [versionCount.value])
: t('Keep this page open.')
}
return t('This moves every version together. Nothing is split.')
})
const failedTitle = computed(() => t("Couldn't move the article"))
const languagesAlert = computed(() => {
const current = result.value
if (current?.kind !== 'languages') {
return null
}
const missing = joinNames(current.missing)
const enabled = current.enabled
? `${t('{0} enables {1} only.', [props.destination, joinNames(current.enabled)])} `
: ''
return {
title: t("{0} doesn't enable every language in this article", [props.destination]),
description:
`${enabled}${t('Enable {0} on {1} first.', [missing, props.destination])} ` +
`${t('Languages disabled on {0} count too.', [props.origin])} ${t('Nothing was moved.')}`,
}
})
const slugAlert = computed(() => {
const current = result.value
if (current?.kind !== 'slug') {
return null
}
const sentences = current.conflicts.map((conflict) =>
t('The {0} address "{1}" is already used by another {0} article on {2}.', [
conflict.language,
conflict.slug,
props.destination,
]),
)
return {
title: t('An address is already used on {0}', [props.destination]),
description: [...sentences, t('Change it before moving. Nothing was moved.')].join(' '),
}
})
async function confirm() {
stage.value = 'moving'
const outcome = await props.run()
result.value = outcome
if (outcome.kind === 'moved') {
emit('close', true)
} else if (outcome.kind === 'gone') {
emit('close', 'gone')
} else {
stage.value = outcome.kind
}
}
</script>
<template>
<LocaleDialog
:title="title"
:description="description"
:confirm-label="stage === 'perm' ? undefined : moving ? t('Moving…') : t('Move article')"
:cancel-label="stage === 'perm' ? t('Close') : undefined"
:pending="moving"
:confirm-disabled="refused"
@close="emit('close', false)"
@confirm="confirm"
>
<template v-if="stage === 'confirm'">
<div v-if="versions.length > 0" class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{
t('Moves all language versions of this article.')
}}</span>
<span v-for="version in versions" :key="version.name" class="text-toned"
>{{ version.name }} · {{ version.status }}</span
>
</div>
<LocaleAlert
tone="neutral"
icon="i-lucide-info"
:title="t('Addresses and links')"
:description="
t(
'Each version keeps its address. Links from the old site to it will stop working. RenewCMS does not create redirects.',
)
"
/>
</template>
<LocaleAlert
v-else-if="moving"
tone="info"
icon="i-lucide-loader-circle"
:title="t('Moving')"
:description="t('This takes a few seconds.')"
/>
<LocaleAlert
v-else-if="languagesAlert"
tone="warning"
icon="i-lucide-triangle-alert"
:title="languagesAlert.title"
:description="languagesAlert.description"
/>
<LocaleAlert
v-else-if="slugAlert"
tone="error"
icon="i-lucide-circle-x"
:title="slugAlert.title"
:description="slugAlert.description"
/>
<LocaleAlert
v-else-if="stage === 'perm'"
tone="error"
icon="i-lucide-circle-x"
:title="t('Moving needs edit permission on every version')"
:description="t('Ask an owner to move it. Nothing was changed.')"
/>
<LocaleAlert
v-else
tone="error"
icon="i-lucide-circle-x"
:title="failedTitle"
:description="t('Nothing was moved. Try again.')"
/>
</LocaleDialog>
</template>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import { SHEET_MOBILE_CLASS } from '@/components/sheetClasses'
import LocaleButton from '@/components/localization/LocaleButton.vue'
import type { Site } from '@/composables/useSites'
import type { MoveVersion } from '@/lib/moveArticle'
const props = defineProps<{
sites: Site[]
currentSiteId: number | null
versions: MoveVersion[]
}>()
const emit = defineEmits<{ close: [siteKey: string | undefined] }>()
const chosenId = ref(props.currentSiteId)
const listbox = ref<HTMLElement | null>(null)
const canMove = computed(() => chosenId.value !== null && chosenId.value !== props.currentSiteId)
const chosenKey = computed(() => props.sites.find((site) => site.id === chosenId.value)?.key)
const openOptions = {
onOpenAutoFocus: (event: Event) => {
event.preventDefault()
nextTick(() => focusOption(props.sites.findIndex((site) => site.id === chosenId.value)))
},
}
function options() {
return Array.from(listbox.value?.querySelectorAll<HTMLElement>('[role="option"]') ?? [])
}
function focusOption(index: number) {
const all = options()
all[Math.max(0, Math.min(index, all.length - 1))]?.focus()
}
function onKeydown(event: KeyboardEvent) {
const index = options().indexOf(document.activeElement as HTMLElement)
const step: Record<string, number> = { ArrowDown: 1, ArrowUp: -1 }
if (event.key in step) {
event.preventDefault()
focusOption(index + step[event.key]!)
}
}
function confirm() {
if (canMove.value) {
emit('close', chosenKey.value)
}
}
</script>
<template>
<UModal
:open="true"
:title="t('Site')"
:description="t('Moves all language versions of this article.')"
:overlay="false"
:content="openOptions"
:ui="{
content: `divide-y-0 shadow-(--shadow-popover) ring-1 ring-(--ui-border) sm:top-53.75 sm:right-6 sm:left-auto sm:max-w-85 sm:translate-x-0 sm:translate-y-0 ${SHEET_MOBILE_CLASS}`,
header: 'sr-only',
body: 'flex flex-col gap-3 p-4 sm:p-4',
}"
@update:open="(value: boolean) => !value && emit('close', undefined)"
>
<template #body>
<span class="font-semibold text-highlighted">{{ t('Site') }}</span>
<div
ref="listbox"
role="listbox"
:aria-label="t('Site')"
class="flex flex-col border border-default"
@keydown="onKeydown"
>
<button
v-for="site in sites"
:key="site.id"
type="button"
role="option"
:aria-selected="site.id === chosenId"
class="flex min-h-12 items-center gap-2 px-3 py-1.5 text-start sm:min-h-9"
:class="
site.id === chosenId
? 'bg-(--rc-sand) font-semibold text-(--rc-on-sand)'
: 'text-highlighted'
"
@click="chosenId = site.id"
>
<span class="w-4">
<UIcon v-if="site.id === chosenId" name="i-lucide-check" class="size-4" />
</span>
<span class="flex-1">{{ site.name }}</span>
<span
v-if="site.id === currentSiteId && site.id !== chosenId"
class="text-xs text-muted"
>{{ t('Current') }}</span
>
</button>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{
versions.length > 0
? t('Moves all language versions of this article.')
: t('Moves the article to the site you choose.')
}}</span>
<span v-for="version in versions" :key="version.name" class="text-toned"
>{{ version.name }} · {{ version.status }}</span
>
</div>
<div class="flex gap-2">
<LocaleButton @click="emit('close', undefined)">{{ t('Cancel') }}</LocaleButton>
<div class="flex flex-1">
<LocaleButton kind="primary" block :disabled="!canMove" @click="confirm">
{{ t('Move article…') }}
</LocaleButton>
</div>
</div>
</template>
</UModal>
</template>
+402 -38
View File
@@ -1,19 +1,53 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { locale, t, translate } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import DirectoryField from '@/components/DirectoryField.vue'
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import LocaleChoiceCards from '@/components/localization/LocaleChoiceCards.vue'
import LocaleSelect from '@/components/localization/LocaleSelect.vue'
import TranslationFailureAlert from '@/components/localization/TranslationFailure.vue'
import { ApiError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import type { Article, StoryItem } from '@/composables/useArticles'
import { useArticles } from '@/composables/useArticles'
import { announce } from '@/composables/useLiveAnnouncement'
import { useNewArticleSites } from '@/composables/useNewArticleSites'
import { useSiteLanguages } from '@/composables/useSiteLanguages'
import { titleVerdict } from '@/lib/editorTitle'
import { languageAfterSiteChange } from '@/lib/languageAfterSiteChange'
import { codeOf, statusOf } from '@/lib/directoryErrors'
import { joinNames } from '@/lib/nameList'
import { languageName } from '@/lib/siteLanguages'
import { defaultSource, sourceLabel } from '@/lib/translationSource'
import {
classifyWithLanguages,
existingArticleOf,
type TranslationFailure,
} from '@/lib/translationFailure'
const TITLE_MIN_LENGTH = 3
const TITLE_MAX_LENGTH = 200
const TITLE_PLACEHOLDER = 'e.g. Choosing tiles for a small bathroom'
const SEARCH_DELAY_MS = 250
const CLOSE_BUTTON = {
color: 'neutral',
variant: 'ghost',
class: 'size-11 justify-center p-0 text-muted md:size-7',
} as const
const PICKER_SIZE = 20
type Kind = 'new' | 'translation'
type Shown = Exclude<TranslationFailure, 'gone' | 'session'>
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ created: [article: Article] }>()
const emit = defineEmits<{ created: [article: Article]; open: [id: number] }>()
const api = useApi()
const { getLanguages } = useSiteLanguages()
const { listStories } = useArticles()
const place = useNewArticleSites()
const { sites, siteKey, site, languages, defaultLang, lang, offered, multilingual } = place
const title = ref('')
const titleTouched = ref(false)
@@ -21,11 +55,17 @@ const titleFocused = ref(false)
const submitting = ref(false)
const titleServerError = ref('')
const formAlert = ref<'' | 'invalid' | 'failed'>('')
const kind = ref<Kind>('new')
const resetFrom = ref<string | null>(null)
const failure = ref<Shown | null>(null)
const existingId = ref<number | undefined>(undefined)
const searchTerm = ref('')
const stories = ref<StoryItem[]>([])
const storyId = ref<number | undefined>(undefined)
let searchTimer: ReturnType<typeof setTimeout> | null = null
const titleLength = computed(() => [...title.value].length)
const titleValid = computed(
() => titleLength.value >= TITLE_MIN_LENGTH && titleLength.value <= TITLE_MAX_LENGTH,
)
const titleValid = computed(() => titleVerdict(title.value) === 'ok')
const titleLengthError = computed(() => {
if (!titleTouched.value) {
@@ -49,6 +89,79 @@ const titleCounterInvalid = computed(
)
const showHelp = computed(() => !submitting.value && formAlert.value === '')
const translating = computed(() => kind.value === 'translation')
const showSite = computed(() => sites.value.length > 0 && !translating.value)
const showLanguage = computed(
() => languages.value !== null && (multilingual.value || resetFrom.value !== null),
)
const showChoice = computed(() => showLanguage.value)
const siteItems = computed(() =>
sites.value.map((entry) => ({ label: entry.name, value: entry.key })),
)
const languageItems = computed(() =>
offered.value.map((tag) => ({ label: languageName(tag, locale.value), value: tag })),
)
const targetName = computed(() => (lang.value ? languageName(lang.value, locale.value) : ''))
const choiceItems = computed(() => [
{
value: 'new',
label: t('New article'),
description: t('Starts an article in this language.'),
},
{
value: 'translation',
label: t('Translation of an existing article'),
description: t('Uses the same rules as Add translation.'),
},
])
const languageHelp = computed(() => {
if (resetFrom.value !== null) {
return t('{0} only offers {1}, so the language was reset from {2}.', [
site.value?.name,
joinNames(offered.value.map((tag) => languageName(tag, locale.value))),
resetFrom.value,
])
}
return lang.value !== undefined && lang.value === defaultLang.value
? t('Default for {0}.', [site.value?.name])
: ''
})
const chosenStory = computed(() => stories.value.find((item) => item.id === storyId.value))
const chosenSource = computed(() =>
chosenStory.value
? defaultSource(languages.value ?? [], chosenStory.value.coverage ?? [])
: undefined,
)
const articleHelp = computed(() =>
chosenSource.value && lang.value
? t('No {0} version yet. Copies from {1}', [
targetName.value,
sourceLabel(chosenSource.value),
])
: '',
)
const storyItems = computed(() =>
stories.value.map((item) => ({ label: item.title, value: item.id })),
)
const titleLabel = computed(() =>
translating.value && targetName.value ? t('Title in {0}', [targetName.value]) : t('Title'),
)
const description = computed(() =>
translating.value
? t('Create a translation of an article you can read.')
: t('Give the article a title. You can write the rest in the editor.'),
)
const confirmLabel = computed(() => {
if (submitting.value) {
return t('Creating…')
}
return translating.value && targetName.value
? t('Create {0} draft', [targetName.value])
: t('Create article')
})
const confirmDisabled = computed(
() => submitting.value || (translating.value && chosenSource.value === undefined),
)
const alertMessage = computed(() => {
if (formAlert.value === 'invalid') {
@@ -67,6 +180,13 @@ function resetForm() {
submitting.value = false
titleServerError.value = ''
formAlert.value = ''
kind.value = 'new'
resetFrom.value = null
failure.value = null
existingId.value = undefined
searchTerm.value = ''
stories.value = []
storyId.value = undefined
}
async function focusTitleField() {
@@ -78,6 +198,12 @@ async function focusTitleField() {
titleFocused.value = true
}
async function loadPlace() {
await place.loadSites()
await place.loadLanguages()
lang.value = defaultLang.value ?? undefined
}
watch(
open,
(isOpen) => {
@@ -86,6 +212,7 @@ watch(
}
resetForm()
focusTitleField()
loadPlace()
},
{ immediate: true },
)
@@ -95,6 +222,171 @@ function onTitleBlur() {
titleFocused.value = false
}
async function onSiteChange(key: string | undefined) {
if (key === undefined || key === siteKey.value) {
return
}
const previous = lang.value
const wasShown = showLanguage.value
siteKey.value = key
resetFrom.value = null
await place.loadLanguages()
if (siteKey.value !== key) {
return
}
if (languages.value === null) {
lang.value = undefined
return
}
const next = languageAfterSiteChange(previous, offered.value, defaultLang.value)
lang.value = next.lang
if (next.replaced && wasShown) {
resetFrom.value = languageName(previous!, locale.value)
}
}
function onLanguageChange(tag: string | undefined) {
lang.value = tag
resetFrom.value = null
if (failure.value && failure.value !== 'unreadable' && failure.value !== 'failed') {
failure.value = null
}
storyId.value = undefined
searchStories()
}
function onKindChange(value: string | undefined) {
kind.value = value as Kind
failure.value = null
if (translating.value) {
searchStories()
}
}
async function fetchStories() {
if (!translating.value || !lang.value) {
stories.value = []
return
}
try {
const page = await listStories({
page: 1,
size: PICKER_SIZE,
q: searchTerm.value.trim(),
lang: lang.value,
tab: 'missing',
site: siteKey.value,
})
stories.value = page.items
} catch {
stories.value = []
}
}
function searchStories() {
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(fetchStories, SEARCH_DELAY_MS)
}
watch(searchTerm, searchStories)
onBeforeUnmount(() => {
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
})
function newArticleBody() {
const body: Record<string, unknown> = { title: title.value }
if (siteKey.value !== undefined && showSite.value) {
body.site = siteKey.value
if (showLanguage.value && lang.value) {
body.lang = lang.value
}
}
return body
}
async function refreshAfterRefusal(error: ApiError) {
if (error.status === 404) {
await place.loadSites()
}
await place.loadLanguages()
lang.value = defaultLang.value ?? undefined
resetFrom.value = null
}
function isRefusedPlace(error: unknown): error is ApiError {
const status = statusOf(error)
const code = codeOf(error)
return (
(status === 409 && code === 'language_unavailable') ||
(status === 422 && code === 'language_not_enabled') ||
(status === 404 && code === 'site_not_found')
)
}
async function submitNew() {
try {
const article = await api.request<Article>('/v1/article/?format=html', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newArticleBody()),
onMissingPermission: 'passthrough',
})
open.value = false
emit('created', article)
} catch (error) {
if (error instanceof ApiError && error.status === 400) {
titleServerError.value = t('Check this title and try again.')
formAlert.value = 'invalid'
return
}
formAlert.value = 'failed'
if (isRefusedPlace(error)) {
await refreshAfterRefusal(error)
}
}
}
async function reloadLanguages() {
return (await getLanguages(siteKey.value ?? '')).languages
}
async function submitTranslation() {
const story = chosenStory.value
const source = chosenSource.value
if (!story || !source || !lang.value) {
return
}
try {
const article = await api.request<Article>('/v1/article/?format=html', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title.value,
lang: lang.value,
story_id: story.story_id,
source_article_id: source.id,
copy: true,
}),
onMissingPermission: 'passthrough',
})
announce(t('{0} draft created', [targetName.value]))
open.value = false
emit('created', article)
} catch (error) {
const outcome = await classifyWithLanguages(error, lang.value, reloadLanguages)
if (outcome === 'session') {
return
}
failure.value = outcome === 'gone' ? 'unreadable' : outcome
existingId.value = existingArticleOf(error)
}
}
async function onSubmit() {
titleTouched.value = true
titleFocused.value = false
@@ -105,27 +397,22 @@ async function onSubmit() {
titleServerError.value = ''
formAlert.value = ''
failure.value = null
submitting.value = true
try {
const article = await api.request<Article>('/v1/article/?format=html', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.value }),
})
open.value = false
emit('created', article)
} catch (error) {
if (error instanceof ApiError && error.status === 400) {
titleServerError.value = 'Check this title and try again.'
formAlert.value = 'invalid'
} else {
formAlert.value = 'failed'
}
await (translating.value ? submitTranslation() : submitNew())
} finally {
submitting.value = false
}
}
function openExisting() {
if (existingId.value !== undefined) {
open.value = false
emit('open', existingId.value)
}
}
function onCancel() {
if (submitting.value) {
return
@@ -139,21 +426,21 @@ function onCancel() {
<UModal
v-model:open="open"
:title="t('New article')"
:description="t('It\'s saved as a draft. Titles can\'t be changed later, so check it now.')"
:description="description"
:dismissible="!submitting"
:close="submitting ? false : CLOSE_BUTTON"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-140 sm:translate-y-0 max-sm:!inset-x-0 max-sm:!top-auto max-sm:!bottom-0 max-sm:!left-0 max-sm:!w-auto max-sm:!max-w-none max-sm:before:absolute max-sm:before:top-2 max-sm:before:left-1/2 max-sm:before:h-1 max-sm:before:w-9 max-sm:before:-translate-x-1/2 max-sm:before:bg-accented max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'items-start gap-3 px-6 pt-7.5 pb-0 sm:px-6 sm:pt-5',
wrapper: 'pe-10 max-sm:pe-18',
close: 'max-sm:end-8 max-sm:top-9',
title: 'text-lg/6 font-semibold',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
content: `${SHEET_CONTENT_CLASS} max-sm:max-h-11/12 sm:top-25 sm:max-h-169 sm:max-w-115`,
close: 'end-6 top-8 md:top-5',
header: 'min-h-16 items-start px-6 pt-5 pb-0 sm:min-h-12 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: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0',
footer: SHEET_FOOTER_CLASS,
}"
>
<template #body>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3.5">
<UAlert
v-if="alertMessage"
role="alert"
@@ -164,13 +451,57 @@ function onCancel() {
:ui="{ root: 'gap-2 px-3 py-2.5' }"
:description="alertMessage"
/>
<UForm :state="{ title }" class="flex flex-col gap-4">
<TranslationFailureAlert
v-if="failure"
:kind="failure"
:language="targetName"
:can-open="existingId !== undefined"
@open="openExisting"
/>
<UForm :state="{ title }" class="flex flex-col gap-3.5">
<template v-if="translating">
<DirectoryField :label="t('Article')" :help="articleHelp">
<USelectMenu
v-model="storyId"
v-model:search-term="searchTerm"
ignore-filter
:portal="false"
:content="{ position: 'inline' }"
:items="storyItems"
value-key="value"
:search-input="{ placeholder: t('Search articles') }"
trailing-icon="i-lucide-chevrons-up-down"
:placeholder="t('Choose an article')"
:disabled="submitting"
class="w-full"
:ui="{
base: 'h-11 rounded-none text-sm ring-(--ui-border-accented) md:h-8',
trailingIcon: 'size-3.5 text-muted',
content: 'rounded-none shadow-(--shadow-popover)',
item: 'min-h-12 gap-2 px-2 py-1 data-[state=checked]:bg-elevated md:min-h-9',
}"
>
<template #empty>{{
t('No article is missing this language.')
}}</template>
</USelectMenu>
</DirectoryField>
<DirectoryField :label="t('Language')">
<LocaleSelect
:model-value="lang"
:items="languageItems"
:aria-label="t('Language')"
:readonly="submitting"
@update:model-value="onLanguageChange"
/>
</DirectoryField>
</template>
<UFormField
:label="t('Title')"
:label="titleLabel"
name="title"
:error="translate(titleError) || undefined"
>
<template #hint>
<template v-if="!translating" #hint>
<span
class="font-mono text-xs leading-tight"
:class="titleCounterInvalid ? 'text-error' : 'text-muted'"
@@ -186,7 +517,7 @@ function onCancel() {
{{ t(error) }}
</span>
</template>
<template v-else-if="showHelp" #help>{{
<template v-else-if="showHelp && !translating" #help>{{
t('3 to 200 characters.')
}}</template>
<UInput
@@ -195,12 +526,45 @@ function onCancel() {
:disabled="submitting"
color="primary"
:highlight="titleFocused || !!titleError"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-base' }"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
class="w-full"
@focus="titleFocused = true"
@blur="onTitleBlur"
/>
</UFormField>
<template v-if="!translating">
<DirectoryField v-if="showSite" :label="t('Site')">
<LocaleSelect
:model-value="siteKey"
:items="siteItems"
:aria-label="t('Site')"
:readonly="submitting"
@update:model-value="onSiteChange"
/>
</DirectoryField>
<DirectoryField
v-if="showLanguage"
:label="t('Language')"
:help="languageHelp"
>
<LocaleSelect
:model-value="lang"
:items="languageItems"
:aria-label="t('Language')"
:readonly="submitting"
@update:model-value="onLanguageChange"
/>
</DirectoryField>
</template>
<LocaleChoiceCards
v-if="showChoice"
:model-value="kind"
:items="choiceItems"
:compact="translating"
:legend="t('New article or translation')"
:disabled="submitting"
@update:model-value="onKindChange"
/>
</UForm>
</div>
</template>
@@ -222,10 +586,10 @@ function onCancel() {
:ui="{ base: 'h-11 md:h-8' }"
class="sm:w-auto"
:loading="submitting"
:disabled="submitting"
:disabled="confirmDisabled"
@click="onSubmit"
>
{{ submitting ? t('Creating…') : t('Create draft') }}
{{ confirmLabel }}
</UButton>
</div>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import DirectoryField from '@/components/DirectoryField.vue'
import type { Site } from '@/composables/useSites'
import { hostOf } from '@/lib/directory'
const props = defineProps<{ sites: Site[]; siteId: number | null }>()
const emit = defineEmits<{ move: [] }>()
const chosen = computed(() => props.sites.find((site) => site.id === props.siteId))
const chosenHost = computed(() => (chosen.value ? hostOf(chosen.value.base_url) : ''))
</script>
<template>
<div class="flex flex-col gap-1.5">
<DirectoryField :label="t('Site')">
<button
type="button"
aria-haspopup="dialog"
:aria-label="t('Site')"
class="flex h-11 w-full items-center justify-between gap-2 border border-accented bg-default px-2.5 text-start text-lg lg:h-10 lg:text-sm"
@click="emit('move')"
>
<span v-if="chosen" class="truncate text-highlighted"
>{{ chosen.name }} · {{ chosenHost }}</span
>
<UIcon name="i-lucide-chevrons-up-down" class="size-3.5 shrink-0 text-muted" />
</button>
</DirectoryField>
<p v-if="chosen" class="flex items-center gap-1.5 text-xs/4.5 text-muted">
<UIcon name="i-lucide-link" class="size-3 shrink-0" />
<span
>{{ t('Canonical host:') }}
<span class="font-mono text-toned">{{ chosenHost }}</span></span
>
</p>
</div>
</template>
@@ -55,6 +55,12 @@ async function clickButton(text: string) {
let requestMock: ReturnType<typeof vi.fn>
function postCalls() {
return requestMock.mock.calls.filter(
(call) => (call[1] as RequestInit | undefined)?.method === 'POST',
)
}
function mountModal() {
requestMock = vi.fn()
vi.mocked(useApi).mockReturnValue({
@@ -104,8 +110,9 @@ describe('Opening the modal shows the Default state', () => {
expect(document.body.textContent).toContain('New article')
expect(document.body.textContent).toContain(
"It's saved as a draft. Titles can't be changed later, so check it now.",
'Give the article a title. You can write the rest in the editor.',
)
expect(document.body.textContent).not.toContain("Titles can't be changed later")
expect(titleInput().value).toBe('')
expect(document.activeElement).toBe(titleInput())
@@ -116,8 +123,8 @@ describe('Opening the modal shows the Default state', () => {
expect(document.querySelector('textarea')).toBeNull()
expect(findButton('Cancel')).toBeTruthy()
expect(findButton('Create draft')).toBeTruthy()
expect(requestMock).not.toHaveBeenCalled()
expect(findButton('Create article')).toBeTruthy()
expect(postCalls()).toHaveLength(0)
})
})
@@ -132,7 +139,7 @@ describe('Validation — a title under 3 characters on blur', () => {
expect(document.body.textContent).toContain('Use at least 3 characters.')
expect(document.body.textContent).not.toContain('3 to 200 characters.')
expect(document.body.textContent).toContain('2 / 200')
expect(requestMock).not.toHaveBeenCalled()
expect(postCalls()).toHaveLength(0)
})
})
@@ -142,10 +149,10 @@ describe('Validation — submitting with an invalid title shows the error and se
await flushPromises()
await fillTitle('Ti')
await clickButton('Create draft')
await clickButton('Create article')
expect(document.body.textContent).toContain('Use at least 3 characters.')
expect(requestMock).not.toHaveBeenCalled()
expect(postCalls()).toHaveLength(0)
})
})
@@ -169,7 +176,7 @@ describe('Submitting disables the form and cannot be dismissed', () => {
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await clickButton('Create article')
expect(requestMock).toHaveBeenCalledWith(
'/v1/article/?format=html',
@@ -216,7 +223,7 @@ describe('A successful create closes the modal and hands off to the editor with
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await clickButton('Create article')
await flushPromises()
expect(wrapper.emitted('created')).toEqual([[articleFixture()]])
@@ -239,7 +246,7 @@ describe("Title rejected — a 400 that got past this form's own check", () => {
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await clickButton('Create article')
await flushPromises()
expect(titleInput().value).toBe(TITLE)
@@ -269,7 +276,7 @@ describe('Error — a 500 or a network failure keeps the text and offers to retr
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await clickButton('Create article')
await flushPromises()
expect(titleInput().value).toBe(TITLE)
@@ -283,7 +290,17 @@ describe('Error — a 500 or a network failure keeps the text and offers to retr
describe('Retrying after an Error succeeds', () => {
it('sends the same body again and hands off to the editor on success', async () => {
requestMock = vi.fn().mockRejectedValueOnce(new ApiError(500))
let posts = 0
requestMock = vi.fn(async (_path: string, init?: RequestInit) => {
if (init?.method !== 'POST') {
return []
}
posts += 1
if (posts === 1) {
throw new ApiError(500)
}
return articleFixture()
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: requestMock,
@@ -295,21 +312,15 @@ describe('Retrying after an Error succeeds', () => {
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await clickButton('Create article')
await flushPromises()
requestMock.mockResolvedValueOnce(articleFixture())
await clickButton('Create draft')
await clickButton('Create article')
await flushPromises()
expect(requestMock).toHaveBeenCalledTimes(2)
expect(requestMock).toHaveBeenNthCalledWith(
2,
'/v1/article/?format=html',
expect.objectContaining({
body: JSON.stringify({ title: TITLE }),
}),
)
expect(postCalls()).toHaveLength(2)
expect(postCalls()[1]![0]).toBe('/v1/article/?format=html')
expect((postCalls()[1]![1] as RequestInit).body).toBe(JSON.stringify({ title: TITLE }))
expect(wrapper.emitted('created')).toEqual([[articleFixture()]])
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
wrapper.unmount()
@@ -325,6 +336,6 @@ describe('Cancel discards the fields with no confirmation', () => {
await clickButton('Cancel')
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
expect(requestMock).not.toHaveBeenCalled()
expect(postCalls()).toHaveLength(0)
})
})
@@ -0,0 +1,508 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import SelectMenu from '@nuxt/ui/components/SelectMenu.vue'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import LocaleSelect from '@/components/localization/LocaleSelect.vue'
import { initializeLocale } from '@/i18n'
import NewArticleModal from '../NewArticleModal.vue'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const TITLE = 'Spring newsletter'
const SITES = [
{ id: 1, key: 'journal', name: 'Journal', is_default: true },
{ id: 2, key: 'atelier', name: 'Atelier', is_default: false },
{ id: 3, key: 'notes', name: 'Notes', is_default: false },
{ id: 4, key: 'archive', name: 'Archive', is_default: false },
]
const LANGUAGES: Record<string, string[]> = {
journal: ['en', 'fr', 'de', 'ar'],
atelier: ['en', 'fr', 'de'],
notes: ['en'],
archive: ['en'],
}
function languagesBody(key: string) {
return {
revision: `${key}-1`,
default: 'en',
needs_setup: false,
available: [],
languages: (LANGUAGES[key] ?? []).map((lang) => ({
lang,
enabled: true,
is_default: lang === 'en',
version_count: 0,
published_count: 0,
social_locale: null,
social_locale_state: 'not_set',
})),
}
}
function story(id: number, storyId: number, title: string) {
return {
id,
story_id: storyId,
title,
coverage: [
{
id,
lang: 'en',
title,
is_online: true,
language_enabled: true,
capabilities: ['read'],
},
],
capabilities: ['read', 'create_translation'],
missing_langs: ['fr'],
}
}
const created = { id: 42, title: TITLE, body: '', is_online: false } as unknown as Article
type Handler = (path: string, init?: RequestInit) => unknown
let routes: Record<string, Handler>
let calls: { method: string; path: string; body: unknown }[]
let wrapper: VueWrapper
function route(key: string, handler: Handler) {
routes[key] = handler
}
const Host = defineComponent({
components: { NewArticleModal },
emits: ['created', 'open'],
setup(_props, { emit }) {
const open = ref(true)
return { open, emit }
},
template: `<NewArticleModal v-model:open="open" @created="emit('created', $event)" @open="emit('open', $event)" />`,
})
async function mountModal() {
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: vi.fn(async (path: string, init?: RequestInit) => {
const [pathname = ''] = path.split('?')
const method = init?.method ?? 'GET'
calls.push({
method,
path: pathname,
body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined,
})
const handler = routes[`${method} ${pathname}`]
if (!handler) {
throw new Error(`Unexpected request: ${method} ${pathname}`)
}
return handler(path, init)
}),
} as unknown as ReturnType<typeof useApi>)
wrapper = mount(Host, { attachTo: document.body, global: { plugins: [uiPlugin] } })
await flushPromises()
await flushPromises()
}
function select(label: string) {
return wrapper
.findAllComponents(LocaleSelect)
.find((component) => component.props('ariaLabel') === label)
}
async function choose(label: string, value: string) {
select(label)!.vm.$emit('update:modelValue', value)
await flushPromises()
await flushPromises()
}
function text() {
return (document.body.textContent ?? '').replace(/\s+/g, ' ')
}
function titleInput() {
return document.body.querySelector<HTMLInputElement>(
'input[placeholder="e.g. Choosing tiles for a small bathroom"]',
)!
}
async function type(value: string) {
const input = titleInput()
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
function button(label: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(element) => element.textContent?.trim() === label,
)
}
async function press(label: string) {
button(label)!.click()
await flushPromises()
await flushPromises()
}
function posts() {
return calls.filter((call) => call.method === 'POST')
}
beforeEach(() => {
initializeLocale()
calls = []
routes = {
'GET /v1/site/': () => structuredClone(SITES),
'POST /v1/article/': () => created,
}
for (const key of Object.keys(LANGUAGES)) {
route(`GET /v1/site/${key}/languages`, () => languagesBody(key))
}
})
afterEach(() => {
wrapper.unmount()
document.body.innerHTML = ''
vi.useRealTimers()
})
describe('loc.l3.single and loc.l3.multi: the form follows the Site field', () => {
it('shows Title, Site and Language with the default help and the choice for Journal', async () => {
await mountModal()
expect(select('Site')!.props('modelValue')).toBe('journal')
expect(select('Language')!.props('modelValue')).toBe('en')
expect(text()).toContain('Default for Journal.')
expect(text()).toContain('Starts an article in this language.')
expect(text()).toContain('Uses the same rules as Add translation.')
})
it('shows Title and Site only for a single-language site', async () => {
await mountModal()
await choose('Site', 'archive')
expect(select('Site')!.props('modelValue')).toBe('archive')
expect(select('Language')).toBeUndefined()
expect(text()).not.toContain('Translation of an existing article')
})
it('hides Language and the choice and posts the site without lang when the languages request fails', async () => {
route('GET /v1/site/journal/languages', () => {
throw new NetworkError()
})
await mountModal()
await type(TITLE)
await press('Create article')
expect(select('Language')).toBeUndefined()
expect(text()).not.toContain('Translation of an existing article')
expect(posts()[0]!.body).toEqual({ title: TITLE, site: 'journal' })
})
})
describe('The Site field lists the sites and starts on the default site', () => {
it('lists the sites in the order of the response with Journal chosen', async () => {
await mountModal()
expect(select('Site')!.props('items')).toEqual([
{ label: 'Journal', value: 'journal' },
{ label: 'Atelier', value: 'atelier' },
{ label: 'Notes', value: 'notes' },
{ label: 'Archive', value: 'archive' },
])
expect(select('Site')!.props('modelValue')).toBe('journal')
})
it('still shows the field on a one-site install', async () => {
route('GET /v1/site/', () => [
SITES[3]!.id === 4 ? { ...SITES[3]!, is_default: true } : SITES[3],
])
await mountModal()
expect(select('Site')!.props('items')).toEqual([{ label: 'Archive', value: 'archive' }])
expect(select('Site')!.props('modelValue')).toBe('archive')
})
it.each([
[
'fails',
() => {
throw new NetworkError()
},
],
['is empty', () => []],
])('creates with the title only when the sites request %s', async (_name, handler) => {
route('GET /v1/site/', handler)
await mountModal()
await type(TITLE)
expect(select('Site')).toBeUndefined()
expect(select('Language')).toBeUndefined()
await press('Create article')
expect(posts()[0]!.body).toEqual({ title: TITLE })
})
})
describe('loc.l3.sitechange: Changing the site', () => {
it('resets a language the new site does not offer and explains it', async () => {
await mountModal()
await type(TITLE)
await choose('Language', 'fr')
await choose('Site', 'notes')
expect(select('Language')!.props('modelValue')).toBe('en')
expect(text()).toContain(
'Notes only offers English, so the language was reset from French.',
)
expect(titleInput().value).toBe(TITLE)
expect(text()).toContain('Translation of an existing article')
})
it('ignores a slow answer of an earlier site once a later site is chosen', async () => {
let release!: (value: unknown) => void
route(
'GET /v1/site/archive/languages',
() =>
new Promise((resolve) => {
release = resolve
}),
)
await mountModal()
await choose('Site', 'archive')
await choose('Site', 'atelier')
await choose('Language', 'de')
release(languagesBody('archive'))
await flushPromises()
await flushPromises()
expect(select('Site')!.props('modelValue')).toBe('atelier')
expect(select('Language')!.props('modelValue')).toBe('de')
})
it('clears the reset help on the next change', async () => {
await mountModal()
await choose('Language', 'fr')
await choose('Site', 'notes')
await choose('Site', 'atelier')
expect(text()).not.toContain('was reset from')
})
it('keeps a language the new site offers without a message', async () => {
await mountModal()
await choose('Language', 'fr')
await choose('Site', 'atelier')
expect(select('Language')!.props('modelValue')).toBe('fr')
expect(text()).not.toContain('was reset from')
expect(text()).not.toContain('Default for Atelier.')
})
it('hides Language and the choice and posts the site when the languages of the new site cannot be read', async () => {
route('GET /v1/site/atelier/languages', () => {
throw new ApiError(500)
})
await mountModal()
await type(TITLE)
await choose('Site', 'atelier')
expect(select('Language')).toBeUndefined()
await press('Create article')
expect(posts()[0]!.body).toEqual({ title: TITLE, site: 'atelier' })
})
})
describe('The modal copy and the create request', () => {
it('says Create article and drops the old sentence', async () => {
await mountModal()
expect(text()).toContain('Give the article a title. You can write the rest in the editor.')
expect(button('Create article')).toBeTruthy()
expect(text()).not.toContain("Titles can't be changed later")
})
it('posts the title, language and site for Atelier and French', async () => {
await mountModal()
await choose('Site', 'atelier')
await choose('Language', 'fr')
await type('Couts')
await press('Create article')
expect(posts()[0]!.body).toEqual({ title: 'Couts', lang: 'fr', site: 'atelier' })
expect(wrapper.emitted('created')).toHaveLength(1)
})
it('always sends the default site key with its language', async () => {
await mountModal()
await type('Plain')
await press('Create article')
expect(posts()[0]!.body).toEqual({ title: 'Plain', lang: 'en', site: 'journal' })
})
})
describe('Failures of a new article', () => {
it.each([
[409, 'language_unavailable'],
[422, 'language_not_enabled'],
])('refetches the languages and resets on %s %s', async (status, body) => {
await mountModal()
await choose('Language', 'fr')
await type(TITLE)
route('POST /v1/article/', () => {
throw new ApiError(status, undefined, body)
})
const before = calls.filter((call) => call.path === '/v1/site/journal/languages').length
await press('Create article')
expect(calls.filter((call) => call.path === '/v1/site/journal/languages')).toHaveLength(
before + 1,
)
expect(select('Language')!.props('modelValue')).toBe('en')
expect(text()).toContain(
"Couldn't create the article. Your text is still here. Try again in a moment.",
)
})
it('refetches the sites and returns to the default on site_not_found', async () => {
await mountModal()
await choose('Site', 'atelier')
await type(TITLE)
route('POST /v1/article/', () => {
throw new ApiError(404, undefined, 'site_not_found')
})
await press('Create article')
expect(calls.filter((call) => call.path === '/v1/site/')).toHaveLength(2)
expect(select('Site')!.props('modelValue')).toBe('journal')
expect(titleInput().value).toBe(TITLE)
expect(text()).toContain("Couldn't create the article.")
})
it.each([
[409, 'setup_required'],
[403, 'missing_permission'],
])('shows the Error state on %s %s without a global toast', async (status, body) => {
await mountModal()
await type(TITLE)
route('POST /v1/article/', () => {
throw new ApiError(status, undefined, body)
})
await press('Create article')
expect(text()).toContain("Couldn't create the article.")
const post = vi
.mocked(useApi)
.mock.results[0]!.value.request.mock.calls.find(
(call: unknown[]) => (call[1] as RequestInit | undefined)?.method === 'POST',
)
expect((post[1] as { onMissingPermission?: string }).onMissingPermission).toBe(
'passthrough',
)
})
})
describe('loc.l3.translation: Translation of an existing article', () => {
const roof = story(10, 5, 'Roof inspection checklist')
beforeEach(() => {
route('GET /v1/article/', () => ({
items: [roof],
total: 1,
page: 1,
size: 20,
counts: { all: 1, online: 1, draft: 0, missing: 1 },
}))
})
async function startTranslation() {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountModal()
await type('Spring')
await choose('Language', 'fr')
wrapper
.findComponent({ name: 'LocaleChoiceCards' })
.vm.$emit('update:modelValue', 'translation')
await flushPromises()
await vi.advanceTimersByTimeAsync(300)
await flushPromises()
}
it('drops the Site field, changes the copy and queries the missing tab', async () => {
await startTranslation()
expect(select('Site')).toBeUndefined()
expect(text()).toContain('Create a translation of an article you can read.')
expect(button('Create French draft')).toBeTruthy()
const list = calls.find((call) => call.path === '/v1/article/')
expect(list).toBeDefined()
})
it('posts the story, the default-language source and no site', async () => {
await startTranslation()
const picker = wrapper.findComponent(SelectMenu)
picker.vm.$emit('update:modelValue', 10)
await flushPromises()
expect(text()).toContain('No French version yet. Copies from English · published')
await type('Liste de controle')
await press('Create French draft')
expect(posts()[0]!.body).toEqual({
title: 'Liste de controle',
lang: 'fr',
story_id: 5,
source_article_id: 10,
copy: true,
})
expect(wrapper.emitted('created')).toHaveLength(1)
})
it('keeps the title and the site when switching back to a new article', async () => {
await startTranslation()
wrapper.findComponent({ name: 'LocaleChoiceCards' }).vm.$emit('update:modelValue', 'new')
await flushPromises()
expect(titleInput().value).toBe('Spring')
expect(select('Site')!.props('modelValue')).toBe('journal')
})
it('shows Open French version when the language exists', async () => {
await startTranslation()
wrapper.findComponent(SelectMenu).vm.$emit('update:modelValue', 10)
await flushPromises()
route('POST /v1/article/', () => {
throw new ApiError(409, undefined, 'language_exists', { existingArticleId: 11 })
})
await type('Liste de controle')
await press('Create French draft')
expect(text()).toContain('French already exists for this article')
await press('Open French version')
expect(wrapper.emitted('open')).toEqual([[11]])
})
it('shows the unreadable-source alert, never a deletion claim, when the source answers 404 Article not found', async () => {
await startTranslation()
wrapper.findComponent(SelectMenu).vm.$emit('update:modelValue', 10)
await flushPromises()
route('POST /v1/article/', () => {
throw new ApiError(404, undefined, 'Article not found')
})
await type('Liste de controle')
await press('Create French draft')
expect(text()).toContain("The source isn't available to you")
expect(text()).not.toContain('deleted')
expect(wrapper.emitted('created')).toBeUndefined()
})
})
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
items: { value: string; label: string; description: string }[]
legend: string
disabled?: boolean
compact?: boolean
}>()
const shown = computed(() =>
props.compact ? props.items.map((item) => ({ ...item, description: undefined })) : props.items,
)
const model = defineModel<string>()
</script>
<template>
<URadioGroup
v-model="model"
:items="shown"
:legend="legend"
:disabled="disabled"
variant="card"
:ui="{
root: 'w-full',
legend: 'sr-only',
fieldset: 'flex flex-col gap-2',
item: 'rounded-none border border-accented px-3 py-2.5 has-data-[state=checked]:border-primary has-data-[state=checked]:bg-elevated',
base: 'mt-0.5 size-4 rounded-none',
indicator: 'rounded-none after:size-2 after:rounded-none',
label: 'font-medium text-highlighted',
description: 'text-xs/4.5 text-pretty text-muted',
}"
/>
</template>
@@ -5,12 +5,14 @@ import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClass
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleButton from '@/components/localization/LocaleButton.vue'
withDefaults(
const props = withDefaults(
defineProps<{
title: string
description?: string
confirmLabel: string
confirmLabel?: string
cancelLabel?: string
placement?: string
content?: Record<string, unknown>
tone?: 'primary' | 'danger'
pending?: boolean
confirmDisabled?: boolean
@@ -18,7 +20,10 @@ withDefaults(
}>(),
{
description: undefined,
confirmLabel: undefined,
cancelLabel: undefined,
placement: 'sm:top-25 sm:max-h-169 sm:max-w-115',
content: undefined,
tone: 'primary',
pending: false,
confirmDisabled: false,
@@ -42,10 +47,11 @@ const CLOSE_BUTTON = {
:description="description"
:dismissible="!pending"
:close="pending ? false : CLOSE_BUTTON"
:content="content"
:ui="{
content: `${SHEET_CONTENT_CLASS} sm:top-25 sm:max-w-115`,
content: `${SHEET_CONTENT_CLASS} max-sm:max-h-11/12 ${props.placement}`,
close: 'end-6 top-8 md:top-5',
header: 'min-h-0 items-start px-6 pt-5 pb-0 sm:px-6',
header: 'min-h-16 shrink-0 items-start px-6 pt-5 pb-0 sm:min-h-12 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: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0',
@@ -71,11 +77,13 @@ const CLOSE_BUTTON = {
{{ cancelLabel ?? t('Cancel') }}
</LocaleButton>
<LocaleButton
v-if="confirmLabel"
:kind="tone === 'danger' ? 'danger' : 'primary'"
:disabled="confirmDisabled || pending"
:loading="pending"
block
class="md:w-auto"
:class="pending ? 'disabled:opacity-100' : ''"
@click="emit('confirm')"
>
{{ confirmLabel }}
@@ -5,6 +5,7 @@ defineProps<{
ariaLabel?: string
invalid?: boolean
readonly?: boolean
shaded?: boolean
hasValue?: boolean
}>()
@@ -28,7 +29,7 @@ const model = defineModel<string | undefined>()
base: [
'h-8 rounded-none border bg-default px-2.5 text-sm ring-0 max-md:h-11',
invalid ? 'border-error' : 'border-accented',
readonly ? 'bg-elevated' : '',
shaded ? 'bg-elevated!' : '',
],
trailingIcon: 'size-3.5 text-muted',
placeholder: 'text-muted',
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleButton from '@/components/localization/LocaleButton.vue'
import type { TranslationFailure } from '@/lib/translationFailure'
type Shown = Exclude<TranslationFailure, 'gone' | 'session'>
const props = defineProps<{
kind: Shown
language: string
canOpen?: boolean
}>()
const emit = defineEmits<{ open: [] }>()
const copy = computed(() => {
const language = props.language
const copies: Record<Shown, { tone: 'error' | 'warning'; title: string; description: string }> =
{
exists: {
tone: 'error',
title: t('{0} already exists for this article', [language]),
description: t('Open it to continue. Your entries are kept here.'),
},
hidden: {
tone: 'error',
title: t("{0} can't be created for this article", [language]),
description: t('Try a different language, or ask an owner. Your entries are kept.'),
},
langOff: {
tone: 'warning',
title: t('{0} was disabled', [language]),
description: t(
'{0} was disabled on this site while you were filling this in. Ask an owner to re-enable it, or choose another language. Your entries are kept.',
[language],
),
},
unreadable: {
tone: 'warning',
title: t("The source isn't available to you"),
description: t(
'You can no longer read the version you chose. Choose another source you can read, or start empty. Your title is kept.',
),
},
failed: {
tone: 'error',
title: t("Couldn't create the translation"),
description: t('Your entries are kept. Try again.'),
},
}
return copies[props.kind]
})
</script>
<template>
<LocaleAlert
:tone="copy.tone"
:icon="copy.tone === 'error' ? 'i-lucide-circle-x' : 'i-lucide-triangle-alert'"
:title="copy.title"
:description="copy.description"
>
<template v-if="kind === 'exists' && canOpen" #actions>
<LocaleButton small @click="emit('open')">
{{ t('Open {0} version', [language]) }}
</LocaleButton>
</template>
</LocaleAlert>
</template>
@@ -325,3 +325,100 @@ describe('useApi requestPaged', () => {
await expect(useApi().request('/v1/site/')).resolves.toEqual([{ id: 1 }])
})
})
function textResponse(status: number, body: string, headers: Record<string, string> = {}) {
return { ...mockResponse(status, null, headers), text: async () => body } as unknown as Response
}
describe('useApi exposes the response headers of a refusal', () => {
it('carries the four localization headers on a 409', async () => {
fetchMock.mockResolvedValue(
textResponse(409, 'language_exists', {
'X-Existing-Article-Id': '11',
'X-Current-Revision': '4',
'X-Missing-Languages': 'fr, de',
'X-Conflicting-Articles': '12,13',
}),
)
const call = useApi().request('/v1/article/12/translations', { method: 'POST' })
await expectApiError(call, {
status: 409,
code: 'language_exists',
existingArticleId: 11,
currentRevision: 4,
missingLanguages: ['fr', 'de'],
conflictingArticles: [12, 13],
})
})
it('leaves the headers undefined when the response has none', async () => {
fetchMock.mockResolvedValue(textResponse(409, 'revision_conflict'))
const call = useApi().request('/v1/article/12', { method: 'PATCH' })
await expectApiError(call, {
code: 'revision_conflict',
existingArticleId: undefined,
currentRevision: undefined,
missingLanguages: undefined,
conflictingArticles: undefined,
})
})
it('ignores malformed numeric and blank list headers', async () => {
fetchMock.mockResolvedValue(
textResponse(409, 'revision_conflict', {
'X-Existing-Article-Id': 'abc',
'X-Current-Revision': '-4',
'X-Missing-Languages': ' ',
'X-Conflicting-Articles': 'abc, 0, -3',
}),
)
const call = useApi().request('/v1/article/12', { method: 'PATCH' })
await expectApiError(call, {
existingArticleId: undefined,
currentRevision: undefined,
missingLanguages: undefined,
conflictingArticles: undefined,
})
})
it('keeps only positive integer ids of the conflicting articles header', async () => {
fetchMock.mockResolvedValue(
textResponse(409, 'slug_conflict', { 'X-Conflicting-Articles': '12, abc, 1.5, 0, 13' }),
)
const call = useApi().request('/v1/story/5/move', { method: 'POST' })
await expectApiError(call, { conflictingArticles: [12, 13] })
})
it('carries the headers on a 403 as well', async () => {
fetchMock.mockResolvedValue(
textResponse(403, 'missing_permission', { 'X-Current-Revision': '7' }),
)
const call = useApi().request('/v1/story/5/move', {
method: 'POST',
onMissingPermission: 'passthrough',
})
await expectApiError(call, { status: 403, currentRevision: 7 })
})
it('keeps the missing-permission toast by default and honours passthrough', async () => {
fetchMock.mockResolvedValue(textResponse(403, 'missing_permission'))
const call = useApi().request('/v1/story/5/move', {
method: 'POST',
onMissingPermission: 'passthrough',
})
await expectApiError(call, { status: 403, code: 'missing_permission' })
expect(document.body.textContent).not.toContain('permission')
})
})
@@ -126,3 +126,111 @@ describe('A 401 response mirrors the body to sessionStorage', () => {
)
})
})
function sentBody(fetchMock: ReturnType<typeof vi.fn>) {
return JSON.parse((fetchMock.mock.calls[0]![1] as RequestInit).body as string)
}
function loadedAt(revision: number | undefined, title = 'Couts') {
setActivePinia(createPinia())
const draft = useArticleDraft(12, USER_ID)
draft.load('<p>Original</p>', new Date(), undefined, undefined, title, revision)
return draft
}
describe('Saving sends the revision the editor loaded', () => {
it('adds if_revision to the changed fields and takes the revision of the answer', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(mockResponse(200, { id: 12, body: '<p>New</p>', revision: 4 }))
global.fetch = fetchMock as unknown as typeof fetch
const draft = loadedAt(3)
draft.edit('<p>New</p>')
await draft.save()
expect(sentBody(fetchMock)).toEqual({ body: '<p>New</p>', if_revision: 3 })
expect(draft.baseRevision.value).toBe(4)
})
it('can save over a newer revision the dialog showed', async () => {
const fetchMock = vi.fn().mockResolvedValue(mockResponse(200, { id: 12, revision: 5 }))
global.fetch = fetchMock as unknown as typeof fetch
const draft = loadedAt(3)
draft.edit('<p>New</p>')
await draft.save({ revision: 4 })
expect(sentBody(fetchMock).if_revision).toBe(4)
expect(draft.baseRevision.value).toBe(5)
})
it('sends nothing when nothing changed', async () => {
const fetchMock = vi.fn()
global.fetch = fetchMock as unknown as typeof fetch
const draft = loadedAt(3)
expect(await draft.save()).toBe('saved')
expect(fetchMock).not.toHaveBeenCalled()
})
it('answers conflict on a revision_conflict and keeps the edits dirty', async () => {
global.fetch = vi.fn().mockResolvedValue({
...mockResponse(409),
headers: { get: (name: string) => (name === 'X-Current-Revision' ? '4' : null) },
text: async () => 'revision_conflict',
}) as unknown as typeof fetch
const draft = loadedAt(3)
draft.edit('<p>New</p>')
expect(await draft.save()).toBe('conflict')
expect(draft.state.value).toBe('dirty')
expect(draft.body.value).toBe('<p>New</p>')
})
})
describe('A mirrored draft keeps the revision it was written against', () => {
it('mirrors the base revision and restores it on load', () => {
const first = loadedAt(3)
first.edit('<p>Offline</p>')
first.mirrorToStorage()
expect(readStoredDraft(USER_ID, 12)?.revision).toBe(3)
const second = useArticleDraft(12, USER_ID)
second.load('<p>Server</p>', new Date(), undefined, undefined, 'Couts', 5)
expect(second.baseRevision.value).toBe(3)
})
it('uses the revision loaded now for a stored draft that has none', () => {
sessionStorage.setItem(
'renewcms:article-draft:7:12',
JSON.stringify({ body: '<p>Old</p>', mirrored_at: '2026-09-26T14:18:00Z' }),
)
const draft = loadedAt(5)
expect(draft.baseRevision.value).toBe(5)
})
})
describe('Reloading theirs replaces the edits', () => {
it('is clean, forgets the stored draft and takes the fetched revision', () => {
const draft = loadedAt(3)
draft.edit('<p>Mine</p>')
draft.mirrorToStorage()
draft.replaceWith({
id: 12,
title: 'Theirs',
body: '<p>Theirs</p>',
revision: 4,
updated_at: '2026-10-14T14:32:00Z',
} as never)
expect(draft.body.value).toBe('<p>Theirs</p>')
expect(draft.title.value).toBe('Theirs')
expect(draft.state.value).toBe('clean')
expect(readStoredDraft(USER_ID, 12)).toBeNull()
expect(draft.baseRevision.value).toBe(4)
})
})
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { Article } from '@/composables/useArticles'
import type { Site } from '@/composables/useSites'
import { useEditorMove } from '@/composables/useEditorMove'
const opened: string[] = []
const choice = { key: 'journal' }
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: () => ({ request: vi.fn() }) }
})
vi.mock('@/i18n/presentation', () => ({
useLocalizedToast: () => ({ add: vi.fn() }),
useLocalizedOverlay: () => ({
create: (component: { __name?: string }) => ({
open: async () => {
opened.push(component.__name ?? '')
return choice.key
},
}),
}),
}))
function site(id: number, key: string): Site {
return { id, key, name: key } as Site
}
describe('Move article refuses the current site', () => {
it('never opens the confirmation when the panel answers the site the article is on', async () => {
opened.length = 0
const movement = useEditorMove({
article: ref({ id: 12, site_id: 1 } as Article),
sites: ref([site(1, 'journal'), site(2, 'atelier')]),
readable: () => [],
multilingual: () => false,
onMoved: vi.fn(),
onGone: vi.fn(),
refreshSites: vi.fn(),
})
await movement.open()
expect(opened).toHaveLength(1)
})
})
+37 -2
View File
@@ -2,7 +2,7 @@ import { ref } from 'vue'
import { accountDeletedRedirect } from '@/lib/accountDeletion'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { ApiError, errorCodeOf, knownErrorCode } from '@/lib/apiError'
import { ApiError, errorCodeOf, knownErrorCode, type ApiErrorDetails } from '@/lib/apiError'
import { filenameFromDisposition } from '@/lib/contentDisposition'
import { missingPermissionCopy } from '@/lib/permissionCopy'
import { useLocalizedToast } from '@/i18n/presentation'
@@ -148,6 +148,35 @@ function headerOrUndefined(response: Response, name: string) {
return response.headers.get(name) ?? undefined
}
function numberHeader(response: Response, name: string) {
const value = response.headers.get(name)
return value !== null && /^\d+$/.test(value) ? Number.parseInt(value, 10) : undefined
}
function listHeader(response: Response, name: string) {
const value = response.headers.get(name)
return value === null || value.trim() === ''
? undefined
: value.split(',').map((part) => part.trim())
}
function positiveIdsHeader(response: Response, name: string) {
const ids = (listHeader(response, name) ?? [])
.filter((part) => /^\d+$/.test(part))
.map((part) => Number.parseInt(part, 10))
.filter((id) => id > 0)
return ids.length > 0 ? ids : undefined
}
function responseDetails(response: Response): ApiErrorDetails {
return {
existingArticleId: numberHeader(response, 'X-Existing-Article-Id'),
currentRevision: numberHeader(response, 'X-Current-Revision'),
missingLanguages: listHeader(response, 'X-Missing-Languages'),
conflictingArticles: positiveIdsHeader(response, 'X-Conflicting-Articles'),
}
}
async function failForbidden(
response: Response,
onForbidden: OnForbidden,
@@ -164,7 +193,12 @@ async function failForbidden(
showMissingPermissionToast(permission)
}
}
throw new ApiError(403, undefined, body, { code, permission, escalationKey })
throw new ApiError(403, undefined, body, {
...responseDetails(response),
code,
permission,
escalationKey,
})
}
export function useApi() {
@@ -207,6 +241,7 @@ export function useApi() {
throw new ApiError(response.status, undefined, body, {
code: errorCodeOf(body) ?? knownErrorCode(response.headers.get('X-Error-Code')),
lastRole: headerOrUndefined(response, 'X-Last-Role'),
...responseDetails(response),
})
}
+78 -18
View File
@@ -3,7 +3,7 @@ import { ApiError, useApi } from '@/composables/useApi'
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 { AUTHOR_CODES, codeOf, statusOf } from '@/lib/directoryErrors'
import { TITLE_SERVER_MESSAGE } from '@/lib/editorTitle'
import {
readStoredDraft,
@@ -19,11 +19,11 @@ export type SaveOutcome =
| 'error'
| 'not-found'
| 'unauthorized'
| 'site-gone'
| 'author-gone'
| 'slug-taken'
| 'invalid-url'
| 'invalid-title'
| 'conflict'
export interface ArticleMeta {
siteId: number | null
@@ -32,6 +32,11 @@ export interface ArticleMeta {
const SLUG_TAKEN_CODE = 'slug_taken'
const INVALID_URL_CODE = 'invalid_url'
const REVISION_CONFLICT_CODE = 'revision_conflict'
export interface SaveOptions {
revision?: number
}
export function useArticleDraft(articleId: number, userId: number | null = null) {
const { request } = useApi()
@@ -47,6 +52,8 @@ export function useArticleDraft(articleId: number, userId: number | null = null)
const savedMeta = ref<ArticleMeta>({ siteId: null, authors: [] })
const seo = ref<ArticleSeo>({ ...EMPTY_SEO })
const savedSeo = ref<ArticleSeo>({ ...EMPTY_SEO })
const baseRevision = ref<number | undefined>(undefined)
const lastFailureStatus = ref(0)
const businessState = ref<ArticleDraftBusinessState>('clean')
const savedAt = ref<Date | null>(null)
const offline = ref(typeof navigator === 'undefined' ? false : !navigator.onLine)
@@ -73,10 +80,6 @@ export function useArticleDraft(articleId: number, userId: number | null = null)
offline.value ? 'offline' : businessState.value,
)
function siteChanged() {
return meta.value.siteId !== savedMeta.value.siteId
}
function authorsChanged() {
return !sameIds(meta.value.authors, savedMeta.value.authors)
}
@@ -91,13 +94,16 @@ export function useArticleDraft(articleId: number, userId: number | null = null)
function mirrorToStorage() {
const draft: StoredDraft = { mirrored_at: new Date().toISOString() }
if (baseRevision.value !== undefined) {
draft.revision = baseRevision.value
}
if (titleChanged()) {
draft.title = title.value
}
if (bodyEdited.value) {
draft.body = body.value
}
if (siteChanged() || authorsChanged()) {
if (authorsChanged()) {
draft.meta = { site_id: meta.value.siteId, authors: meta.value.authors }
}
if (Object.keys(seoPatch()).length > 0) {
@@ -116,6 +122,7 @@ export function useArticleDraft(articleId: number, userId: number | null = null)
initialMeta?: ArticleMeta,
initialSeo?: ArticleSeo,
initialTitle = '',
initialRevision?: number,
) {
const stored = readStoredDraft(userId, articleId)
savedAt.value = initialSavedAt
@@ -123,8 +130,9 @@ export function useArticleDraft(articleId: number, userId: number | null = null)
title.value = stored?.title ?? initialTitle
savedMeta.value = initialMeta ?? { siteId: null, authors: [] }
meta.value = stored?.meta
? { siteId: stored.meta.site_id, authors: toBylines(stored.meta.authors) }
? { siteId: savedMeta.value.siteId, authors: toBylines(stored.meta.authors) }
: savedMeta.value
baseRevision.value = stored?.revision ?? initialRevision
savedSeo.value = initialSeo ?? { ...EMPTY_SEO }
seo.value = stored?.seo ? seoOf(stored.seo, savedSeo.value) : savedSeo.value
bodyEdited.value = stored?.body !== undefined
@@ -165,12 +173,12 @@ export function useArticleDraft(articleId: number, userId: number | null = null)
}
}
function savePayload() {
function savePayload(revision: number | undefined) {
return {
...(revision !== undefined ? { if_revision: revision } : {}),
...seoPatch(),
...(titleChanged() ? { title: title.value } : {}),
...(bodyEdited.value ? { body: body.value } : {}),
...(siteChanged() ? { site_id: meta.value.siteId } : {}),
...(authorsChanged()
? { author_ids: meta.value.authors.map((author) => author.id) }
: {}),
@@ -193,26 +201,33 @@ export function useArticleDraft(articleId: number, userId: number | null = null)
if (statusOf(err) !== 404) {
return null
}
const code = codeOf(err)
if (code === SITE_CODES.notFound) {
return 'site-gone'
}
return code === AUTHOR_CODES.notFound ? 'author-gone' : null
return codeOf(err) === AUTHOR_CODES.notFound ? 'author-gone' : null
}
async function save(): Promise<SaveOutcome> {
function conflictOutcome(err: unknown): SaveOutcome | null {
if (statusOf(err) !== 409 || codeOf(err) !== REVISION_CONFLICT_CODE) {
return null
}
return 'conflict'
}
async function save(options: SaveOptions = {}): Promise<SaveOutcome> {
businessState.value = 'saving'
savingTitle.value = titleChanged()
const payload = savePayload()
const revision = options.revision ?? baseRevision.value
lastFailureStatus.value = 0
const changes = savePayload(undefined)
const payload = savePayload(revision)
try {
const saved =
Object.keys(payload).length > 0
Object.keys(changes).length > 0
? await request<Article>(`/v1/article/${articleId}?format=html`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
: undefined
baseRevision.value = saved?.revision ?? baseRevision.value
businessState.value = 'saved'
savingTitle.value = false
savedAt.value = new Date()
@@ -226,6 +241,12 @@ export function useArticleDraft(articleId: number, userId: number | null = null)
return 'saved'
} catch (err) {
savingTitle.value = false
lastFailureStatus.value = statusOf(err)
const conflict = conflictOutcome(err)
if (conflict) {
businessState.value = 'dirty'
return conflict
}
const rejected = rejectedOutcome(err)
if (rejected) {
businessState.value = 'dirty'
@@ -250,7 +271,46 @@ export function useArticleDraft(articleId: number, userId: number | null = null)
}
}
function setBaseRevision(revision: number | undefined) {
baseRevision.value = revision
}
function syncSite(siteId: number | null) {
savedMeta.value = { ...savedMeta.value, siteId }
meta.value = { ...meta.value, siteId }
}
function markFailed() {
businessState.value = 'error'
}
function replaceWith(theirs: Article) {
title.value = theirs.title
savedTitle.value = theirs.title
titleServerError.value = ''
body.value = theirs.body
bodyEdited.value = false
savedMeta.value = {
siteId: theirs.site_id ?? null,
authors: toBylines(theirs.authors ?? []),
}
meta.value = savedMeta.value
savedSeo.value = seoOf(theirs)
seo.value = savedSeo.value
baseRevision.value = theirs.revision
savedAt.value = new Date(theirs.updated_at)
restoredAt.value = null
businessState.value = 'clean'
discardStored()
}
return {
baseRevision,
lastFailureStatus,
setBaseRevision,
syncSite,
markFailed,
replaceWith,
title,
titleServerError,
savingTitle,
+97
View File
@@ -0,0 +1,97 @@
import type { Ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import EditorConflictDialog, { type ConflictResult } from '@/components/EditorConflictDialog.vue'
import { useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import type { SaveOutcome, useArticleDraft } from '@/composables/useArticleDraft'
import { announce } from '@/composables/useLiveAnnouncement'
import { conflictDownloadText } from '@/lib/bodyPlainText'
import { statusOf } from '@/lib/directoryErrors'
import { downloadBlob } from '@/lib/downloadBlob'
interface ConflictOptions {
articleId: number
draft: ReturnType<typeof useArticleDraft>
article: Ref<Article | null>
language: () => string | null
saveOver: (revision: number | undefined) => Promise<SaveOutcome>
onReloaded: (theirs: Article) => void
onGone: () => void
onClosed: (result: ConflictResult) => void
}
const DOWNLOAD_MIME = 'text/plain;charset=utf-8'
const DOWNLOAD_EXTENSION = '.txt'
export function useEditorConflict(options: ConflictOptions) {
const { request } = useApi()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
async function fetchTheirs() {
try {
return await request<Article>(`/v1/article/${options.articleId}?format=html`)
} catch (error) {
if (statusOf(error) === 404) {
options.onGone()
}
return null
}
}
function download() {
const text = conflictDownloadText(options.draft.title.value, options.draft.body.value)
downloadBlob(
new Blob([text], { type: DOWNLOAD_MIME }),
`${options.draft.title.value || 'article'}${DOWNLOAD_EXTENSION}`,
)
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Download started')
},
get description() {
const language = options.language()
return language
? t(
'Your {0} title and body are saved as a text file. Your changes are still in the editor.',
[language],
)
: t(
'Your title and body are saved as a text file. Your changes are still in the editor.',
)
},
}))
announce(t('Download started'))
}
async function saveOver(revision: number | undefined) {
const outcome = await options.saveOver(revision)
return { outcome, status: options.draft.lastFailureStatus.value }
}
async function begin() {
const theirs = await fetchTheirs()
if (!theirs) {
return false
}
overlay
.create(EditorConflictDialog, {
props: {
theirs,
restoredAt: options.draft.restoredAt.value,
fetchTheirs,
attemptSave: saveOver,
download,
applyTheirs: options.onReloaded,
},
})
.open()
.then((result) => options.onClosed(result as ConflictResult))
return true
}
return { begin }
}
+222
View File
@@ -0,0 +1,222 @@
import type { Ref } from 'vue'
import { useRouter } from 'vue-router'
import { t } from '@/i18n'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import EditorTaskDialog, { type TaskResult } from '@/components/EditorTaskDialog.vue'
import { useApi } from '@/composables/useApi'
import type { Article, CoverageEntry } from '@/composables/useArticles'
import { statusOf } from '@/lib/directoryErrors'
import { joinNames } from '@/lib/nameList'
import { nameInSentence } from '@/lib/storyList'
interface DeletionOptions {
article: Ref<Article | null>
readable: () => CoverageEntry[]
language: () => string | null
classicDelete: () => Promise<unknown>
refetch: () => Promise<void>
onDeleted: () => void
}
type Outcome = 'deleted' | 'gone' | 'blocked' | 'partial'
const FORBIDDEN = 403
const NOT_FOUND = 404
export function useEditorDeletion(options: DeletionOptions) {
const { request } = useApi()
const router = useRouter()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
function remove(id: number) {
return request(`/v1/article/${id}`, {
method: 'DELETE',
onMissingPermission: 'passthrough',
})
}
function ask(copy: {
title: string
description: string
confirmLabel: string
cancelLabel?: string
run: () => Promise<TaskResult>
}) {
return overlay
.create(EditorTaskDialog, { props: { ...copy, tone: 'danger' } })
.open() as Promise<unknown>
}
function toastDone(title: string, description?: string) {
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return title
},
get description() {
return description
},
}))
}
function toastPartial() {
toast.add(() => ({
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't delete the whole article")
},
get description() {
return t('Some versions were deleted. Open the language menu to see what is left.')
},
}))
}
function failure(): TaskResult {
return {
done: false,
error: {
title: t(
"Couldn't delete the article, so it's still there. Try again in a moment.",
),
},
}
}
function finish(outcome: Outcome | undefined, doneTitle: string, doneText?: string) {
if (outcome === 'deleted') {
options.onDeleted()
router.push('/article')
toastDone(doneTitle, doneText)
} else if (outcome === 'gone') {
options.onDeleted()
router.push('/article')
}
}
async function removeOpen(): Promise<TaskResult> {
const current = options.article.value
if (!current) {
return failure()
}
try {
await remove(current.id)
return { done: true, value: 'deleted' }
} catch (error) {
return statusOf(error) === NOT_FOUND ? { done: true, value: 'gone' } : failure()
}
}
async function removeAll(): Promise<TaskResult> {
const current = options.article.value
if (!current) {
return failure()
}
const ids = [
...options
.readable()
.filter((entry) => entry.id !== current.id)
.map((entry) => entry.id),
current.id,
]
for (const [index, id] of ids.entries()) {
try {
await remove(id)
} catch (error) {
if (statusOf(error) === NOT_FOUND) {
continue
}
const blocked = index === 0 && statusOf(error) === FORBIDDEN
return { done: true, value: blocked ? 'blocked' : 'partial' }
}
}
return { done: true, value: 'deleted' }
}
async function deleteVersion() {
const language = options.language()
const current = options.article.value
if (!language || !current) {
await options.classicDelete()
return
}
const outcome = (await ask({
title: t('Delete the {0} version?', [language]),
description: t('"{0}" will be deleted. Other versions stay.', [current.title]),
confirmLabel: t('Delete {0} version', [language]),
run: removeOpen,
})) as Outcome | undefined
finish(
outcome,
t('{0} version deleted', [language]),
t('"{0}" is gone. Other versions stay.', [current.title]),
)
}
async function deleteBlocked() {
const language = options.language() ?? ''
const choice = await ask({
title: t("You can't delete this article"),
description: t(
'Deleting an article needs delete permission on every version. You can still delete the version you are editing.',
),
confirmLabel: t('Delete {0} version', [language]),
cancelLabel: t('Close'),
run: async () => ({ done: true, value: 'switch' }),
})
if (choice === 'switch') {
await deleteVersion()
}
}
async function deleteOnlyVersion(language: string) {
const outcome = (await ask({
title: t('Delete the only version?'),
description: t(
'{0} is the only version of this article. Deleting it deletes the article.',
[language],
),
confirmLabel: t('Delete article'),
run: removeOpen,
})) as Outcome | undefined
finish(outcome, t('Article deleted'))
}
async function deleteWholeArticle() {
const names = options.readable().map((entry) => nameInSentence(entry.lang))
const outcome = (await ask({
title: t('Delete {0}?', [joinNames(names)]),
description: t(
"This deletes the article and every version you can read. It can't be undone.",
),
confirmLabel: t('Delete article'),
run: removeAll,
})) as Outcome | undefined
if (outcome === 'blocked') {
await deleteBlocked()
} else if (outcome === 'partial') {
toastPartial()
await options.refetch().catch(() => undefined)
} else {
finish(outcome, t('Article deleted'))
}
}
async function deleteArticle() {
const language = options.language()
const current = options.article.value
if (!language || !current) {
await options.classicDelete()
return
}
if (options.readable().length <= 1) {
await deleteOnlyVersion(language)
return
}
await deleteWholeArticle()
}
return { deleteVersion, deleteArticle }
}
+173
View File
@@ -0,0 +1,173 @@
import type { Ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import MoveArticleDialog from '@/components/MoveArticleDialog.vue'
import MoveArticlePanel from '@/components/MoveArticlePanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
import type { Article, CoverageEntry } from '@/composables/useArticles'
import { announce } from '@/composables/useLiveAnnouncement'
import { useSiteLanguages } from '@/composables/useSiteLanguages'
import type { Site } from '@/composables/useSites'
import { codeOf, statusOf } from '@/lib/directoryErrors'
import { moveVersions, type MoveResult } from '@/lib/moveArticle'
import { nameInSentence } from '@/lib/storyList'
interface MoveOptions {
article: Ref<Article | null>
sites: Ref<Site[]>
readable: () => CoverageEntry[]
multilingual: () => boolean
onMoved: () => Promise<void>
onGone: () => void
refreshSites: () => Promise<void>
}
const FORBIDDEN = 403
const NOT_FOUND = 404
const UNPROCESSABLE = 422
const CONFLICT = 409
export function useEditorMove(options: MoveOptions) {
const { request } = useApi()
const { getLanguages } = useSiteLanguages()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
function readableLangs() {
return options.readable().map((entry) => entry.lang)
}
async function enabledNames(key: string) {
try {
const languages = await getLanguages(key)
return languages.languages
.filter((language) => language.enabled)
.map((language) => nameInSentence(language.lang))
} catch {
return null
}
}
async function languagesRefusal(key: string, error: ApiError): Promise<MoveResult> {
const known = readableLangs()
const missing = (error.missingLanguages ?? []).filter((lang) => known.includes(lang))
return {
kind: 'languages',
missing: missing.map((lang) => nameInSentence(lang)),
enabled: await enabledNames(key),
}
}
async function slugRefusal(error: ApiError): Promise<MoveResult> {
const readable = options.readable()
const ids = (error.conflictingArticles ?? []).filter((id) =>
readable.some((entry) => entry.id === id),
)
const conflicts = await Promise.all(
ids.map(async (id) => {
try {
const found = await request<Article>(`/v1/article/${id}`)
return {
language: nameInSentence(found.lang ?? ''),
slug: found.slug ?? '',
}
} catch {
return null
}
}),
)
return { kind: 'slug', conflicts: conflicts.filter((entry) => entry !== null) }
}
async function refusalOf(error: unknown, key: string): Promise<MoveResult> {
const status = statusOf(error)
const code = codeOf(error)
if (error instanceof ApiError) {
if (status === UNPROCESSABLE && code === 'language_not_enabled') {
return languagesRefusal(key, error)
}
if (status === CONFLICT && code === 'slug_conflict') {
return slugRefusal(error)
}
}
if (status === FORBIDDEN) {
return { kind: 'perm' }
}
if (status === NOT_FOUND && code === 'site_not_found') {
await options.refreshSites()
return { kind: 'failed' }
}
return status === NOT_FOUND ? { kind: 'gone' } : { kind: 'failed' }
}
async function send(key: string): Promise<MoveResult> {
const storyId = options.article.value?.story_id
try {
await request(`/v1/story/${storyId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ site: key }),
onMissingPermission: 'passthrough',
})
return { kind: 'moved' }
} catch (error) {
return refusalOf(error, key)
}
}
function announceMoved(destination: string, count: number) {
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Moved to {0}', [destination])
},
get description() {
return count > 1
? t('All {0} versions moved together.', [count])
: t('The article moved.')
},
}))
announce(t('Moved to {0}', [destination]))
}
async function open() {
const current = options.article.value
if (!current) {
return
}
const versions = options.multilingual() ? moveVersions(options.readable()) : []
const key = (await overlay
.create(MoveArticlePanel, {
props: {
sites: options.sites.value,
currentSiteId: current.site_id ?? null,
versions,
},
})
.open()) as string | undefined
const target = options.sites.value.find((site) => site.key === key)
const origin = options.sites.value.find((site) => site.id === current.site_id)
if (!target || target.id === current.site_id) {
return
}
const outcome = await overlay
.create(MoveArticleDialog, {
props: {
destination: target.name,
origin: origin?.name ?? '',
versions,
run: () => send(target.key),
},
})
.open()
if (outcome === true) {
announceMoved(target.name, Math.max(versions.length, 1))
await options.onMoved()
} else if (outcome === 'gone') {
options.onGone()
}
}
return { open }
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { SEO_LIMITS, charCount, seoChanges, type ArticleSeo } from '@/lib/articl
import type { DirectoryNotice } from '@/lib/directoryTypes'
import type { SeoCheckBody, SeoFocus, SuggestionField } from '@/lib/seoTypes'
const CLOSING_OUTCOMES: SaveOutcome[] = ['not-found', 'site-gone', 'author-gone', 'unauthorized']
const CLOSING_OUTCOMES: SaveOutcome[] = ['not-found', 'author-gone', 'unauthorized']
interface EditorSeoPanelOptions {
articleId: number
+149
View File
@@ -0,0 +1,149 @@
import { watch, type Ref } from 'vue'
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router'
import { locale, t } from '@/i18n'
import { useLocalizedOverlay } from '@/i18n/presentation'
import AddTranslationDialog, {
type AddTranslationResult,
} from '@/components/AddTranslationDialog.vue'
import type { Article, CoverageEntry } from '@/composables/useArticles'
import { ADD_TRANSLATION_DIALOG as DIALOG_NAME } from '@/lib/addTranslationLocation'
import { holds } from '@/lib/editorCapabilities'
import type { VersionRow } from '@/lib/editorVersions'
import { languageName, type SiteLanguage } from '@/lib/siteLanguages'
interface TranslationOptions {
article: Ref<Article | null>
loaded: Ref<boolean>
languages: () => SiteLanguage[]
refetchLanguages: () => Promise<SiteLanguage[]>
switchTo: (row: VersionRow, announcement?: string) => Promise<void>
onGone: () => void
}
function versionRowFor(id: number, lang: string): VersionRow {
return {
key: `version-${id}`,
lang,
name: languageName(lang, locale.value),
code: lang.toUpperCase(),
articleId: id,
title: '',
current: false,
online: false,
readOnly: false,
languageDisabled: false,
addTranslation: false,
}
}
function entryOf(created: Article, lang: string): CoverageEntry {
return {
id: created.id,
lang,
title: created.title,
is_online: created.is_online,
language_enabled: true,
capabilities: created.capabilities,
}
}
export function useEditorTranslation(options: TranslationOptions) {
const route = useRoute()
const router = useRouter()
const overlay = useLocalizedOverlay()
let instance: ReturnType<typeof overlay.create> | null = null
function strippedQuery(): LocationQueryRaw {
const rest = { ...route.query }
delete rest.dialog
delete rest.lang
return rest
}
function stripParameters() {
if (route.query.dialog !== undefined || route.query.lang !== undefined) {
return router.replace({ query: strippedQuery() })
}
return undefined
}
function open(lang?: string) {
return router.push({
query: { ...route.query, dialog: DIALOG_NAME, ...(lang ? { lang } : {}) },
})
}
async function settle(result: AddTranslationResult | undefined) {
if (!result) {
await stripParameters()
return
}
if (result.kind === 'gone') {
await stripParameters()
options.onGone()
return
}
await stripParameters()
if (result.kind === 'open') {
await options.switchTo(versionRowFor(result.id, result.lang))
return
}
const current = options.article.value
if (current) {
options.article.value = {
...current,
translations: [
...(current.translations ?? []),
entryOf(result.article, result.lang),
],
}
}
const name = languageName(result.lang, locale.value)
await options.switchTo(
versionRowFor(result.article.id, result.lang),
t('{0} draft created', [name]),
)
}
async function show(lang: string | undefined) {
const created = overlay.create(AddTranslationDialog, {
props: {
get article() {
return options.article.value!
},
get languages() {
return options.languages()
},
initialLang: lang,
refetchLanguages: options.refetchLanguages,
},
})
instance = created
const result = (await created.open()) as AddTranslationResult | undefined
instance = null
await settle(result)
}
watch(
() => [route.query.dialog, route.query.lang, options.loaded.value] as const,
([dialog, lang, loaded]) => {
if (!loaded) {
return
}
if (dialog !== DIALOG_NAME) {
instance?.close()
return
}
if (!holds(options.article.value?.capabilities, 'create_translation')) {
stripParameters()
return
}
if (!instance) {
show(typeof lang === 'string' ? lang : undefined)
}
},
{ immediate: true },
)
return { open }
}
+21
View File
@@ -46,6 +46,14 @@ export function useEditorVersions(
})
: [],
)
const orderedReadable = computed(() => {
const order = siteLanguages.value.map((language) => language.lang)
const rank = (tag: string) => {
const index = order.indexOf(tag)
return index === -1 ? order.length : index
}
return [...translations.value].sort((left, right) => rank(left.lang) - rank(right.lang))
})
const siblingNames = computed(() => siblings.value.map((entry) => nameInSentence(entry.lang)))
const onlineSiblingNames = computed(() =>
siblings.value
@@ -66,6 +74,16 @@ export function useEditorVersions(
}
}
async function refreshLanguages() {
const current = article.value
const key = sites.value.find((site) => site.id === current?.site_id)?.key
if (!key) {
return siteLanguages.value
}
siteLanguages.value = (await getLanguages(key)).languages
return siteLanguages.value
}
function markOnline(id: number, online: boolean) {
const current = article.value
if (!current?.translations) {
@@ -93,10 +111,13 @@ export function useEditorVersions(
multilingual,
languageDisabled,
siblings,
orderedReadable,
rows,
siblingNames,
onlineSiblingNames,
siteLanguages,
loadLanguages,
refreshLanguages,
markOnline,
markTitle,
}
+65
View File
@@ -0,0 +1,65 @@
import { computed, ref } from 'vue'
import { useSiteLanguages } from '@/composables/useSiteLanguages'
import { useSites, type Site } from '@/composables/useSites'
import { enabledLanguages } from '@/lib/translationSource'
import type { SiteLanguage, SiteLanguages } from '@/lib/siteLanguages'
const MULTILINGUAL_MINIMUM = 2
export function useNewArticleSites() {
const { listAllSites } = useSites()
const { getLanguages } = useSiteLanguages()
const sites = ref<Site[]>([])
const siteKey = ref<string | undefined>(undefined)
const languages = ref<SiteLanguage[] | null>(null)
const defaultLang = ref<string | null>(null)
const lang = ref<string | undefined>(undefined)
const site = computed(() => sites.value.find((entry) => entry.key === siteKey.value))
const offered = computed(() => enabledLanguages(languages.value ?? []).map((l) => l.lang))
const multilingual = computed(() => offered.value.length >= MULTILINGUAL_MINIMUM)
async function loadSites() {
try {
const list = await listAllSites()
sites.value = Array.isArray(list) ? list : []
} catch {
sites.value = []
}
siteKey.value = (sites.value.find((entry) => entry.is_default) ?? sites.value[0])?.key
}
async function loadLanguages() {
languages.value = null
defaultLang.value = null
const key = siteKey.value
if (!key) {
return
}
let loaded: SiteLanguages
try {
loaded = await getLanguages(key)
} catch {
return
}
if (key !== siteKey.value) {
return
}
languages.value = loaded.languages
defaultLang.value = loaded.default
}
return {
sites,
siteKey,
site,
languages,
defaultLang,
lang,
offered,
multilingual,
loadSites,
loadLanguages,
}
}
+4 -4
View File
@@ -41,7 +41,7 @@ export function useVersionSwitch(options: VersionSwitchOptions) {
}))
}
async function open(row: VersionRow) {
async function open(row: VersionRow, announcement?: string) {
opening.value = true
loading.start()
try {
@@ -52,7 +52,7 @@ export function useVersionSwitch(options: VersionSwitchOptions) {
if (failure) {
throw failure
}
announce(t('Editing {0} version', [row.name]))
announce(announcement ?? t('Editing {0} version', [row.name]))
} catch {
options.cancelLeave()
clearStashedArticle()
@@ -87,14 +87,14 @@ export function useVersionSwitch(options: VersionSwitchOptions) {
return choice === 'save' && (await options.save()) === 'saved'
}
async function choose(row: VersionRow) {
async function choose(row: VersionRow, announcement?: string) {
if (row.current || row.addTranslation || row.articleId === null || opening.value) {
return
}
if (options.hasUnsavedChanges() && !(await settleUnsaved(row))) {
return
}
await open(row)
await open(row, announcement)
}
return { choose, loadingVisible: loading.visible }
+2
View File
@@ -1,11 +1,13 @@
import { articleListMessages } from './articleListMessages'
import { editorMessages } from './editorMessages'
import { localizationMessages } from './localizationMessages'
import { translationFlowMessages } from './translationFlowMessages'
export const messages = {
...localizationMessages,
...articleListMessages,
...editorMessages,
...translationFlowMessages,
'Not found': 'Introuvable',
'Edit article': 'Modifier l’article',
unselected: 'désélectionnée',
+1
View File
@@ -19,6 +19,7 @@ const SOURCES = [
'components/SeoAdvancedSection.vue',
'components/SeoSocialSection.vue',
'components/SiteSelect.vue',
'components/SiteMoveField.vue',
'composables/useArticleActions.ts',
'composables/useArticleRowActions.ts',
'composables/useEditorImages.ts',
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { messages } from './messages'
import { translationFlowMessages } from './translationFlowMessages'
const NUMBERED = /\{\d+\}/g
describe('Translation flow messages', () => {
it.each(Object.entries(translationFlowMessages))('translates "%s"', (english, french) => {
expect(french).toMatch(/\S/)
expect(french).not.toBe(english)
expect([...french.matchAll(NUMBERED)].map((match) => match[0]).sort()).toEqual(
[...english.matchAll(NUMBERED)].map((match) => match[0]).sort(),
)
expect((messages as Record<string, string>)[english]).toBe(french)
})
it('says article, never story', () => {
expect(Object.keys(translationFlowMessages).join(' ')).not.toMatch(/\bstory\b/i)
})
})
+155
View File
@@ -0,0 +1,155 @@
export const translationFlowMessages = {
'Start empty': 'Commencer vide',
'Only the title. Freshness shows No source until you pick a tracked source.':
'Seulement le titre. La fraîcheur indique Aucune source tant que vous n’avez pas choisi une source suivie.',
'Copy from {0}': 'Copier depuis {0}',
"Copies body, excerpt, search and social text, image alternative text and authors. Images are reused, files aren't duplicated. The version starts as Needs review.":
'Copie le corps, l’extrait, les textes de recherche et de partage, les textes alternatifs des images et les auteurs. Les images sont réutilisées, les fichiers ne sont pas dupliqués. La version démarre à relire.',
'Creates a {0} draft of "{1}". It isn\'t published.':
'Crée un brouillon en {0} de « {1} ». Il n’est pas publié.',
'Creates a draft of "{0}" in the language you choose. It isn\'t published.':
'Crée un brouillon de « {0} » dans la langue que vous choisissez. Il n’est pas publié.',
'Title in {0}': 'Titre en {0}',
'Copying text is a starting point: it stays in {0} until you translate it.':
'Copier le texte est un point de départ : il reste en {0} jusqu’à ce que vous le traduisiez.',
'Create {0} draft': 'Créer le brouillon en {0}',
'{0} was disabled on this site.': '{0} a été désactivée sur ce site.',
'Add a translation': 'Ajouter une traduction',
'Source version': 'Version source',
'You choose which version to copy from. It is also the source tracked for review. Access is checked again when you create the draft.':
'Vous choisissez la version à copier. C’est aussi la source suivie pour la relecture. L’accès est vérifié à nouveau à la création du brouillon.',
'Choose a version': 'Choisir une version',
'Starting content': 'Contenu de départ',
'Someone else saved this version': 'Quelqu’un d’autre a enregistré cette version',
'Reload theirs?': 'Recharger sa version ?',
"Someone else saved this version at {0}. Your changes aren't saved. They stay here until you choose.":
'Quelqu’un d’autre a enregistré cette version à {0}. Vos modifications ne sont pas enregistrées. Elles restent ici jusqu’à votre choix.',
'This discards your unsaved changes.': 'Cela abandonne vos modifications non enregistrées.',
"Couldn't replace theirs": 'Impossible de remplacer sa version',
'Someone else saved this version again at {0}, before your save went through. Nothing was overwritten. Your changes are still here.':
'Quelqu’un d’autre a de nouveau enregistré cette version à {0}, avant votre enregistrement. Rien n’a été écrasé. Vos modifications sont toujours ici.',
'Nothing was overwritten. Your changes are still here. Try again.':
'Rien n’a été écrasé. Vos modifications sont toujours ici. Réessayez.',
'Your session ended. These changes were kept in this browser.':
'Votre session a pris fin. Ces modifications ont été conservées dans ce navigateur.',
'Keep my changes': 'Garder mes modifications',
'Nothing is sent. Your changes stay in the editor.':
'Rien n’est envoyé. Vos modifications restent dans l’éditeur.',
'Download my version': 'Télécharger ma version',
'Saves your title and body as a text file. Nothing is sent.':
'Enregistre votre titre et votre corps dans un fichier texte. Rien n’est envoyé.',
'Reload theirs': 'Recharger sa version',
'Shows their version. You confirm first.': 'Affiche sa version. Vous confirmez d’abord.',
'Save over theirs': 'Écraser sa version',
'Replaces the title, body and search fields of this version only. Other languages are untouched.':
'Remplace le titre, le corps et les champs de recherche de cette version seulement. Les autres langues ne changent pas.',
'Reload and discard my changes': 'Recharger et abandonner mes modifications',
"You can't move this article": 'Vous ne pouvez pas déplacer cet article',
'Moving to {0}…': 'Déplacement vers {0}…',
'Move to {0}?': 'Déplacer vers {0} ?',
'All {0} versions move together. Keep this page open.':
'Les {0} versions se déplacent ensemble. Gardez cette page ouverte.',
'Keep this page open.': 'Gardez cette page ouverte.',
'This moves every version together. Nothing is split.':
'Toutes les versions se déplacent ensemble. Rien n’est séparé.',
"Couldn't move the article": 'Impossible de déplacer l’article',
'{0} enables {1} only.': '{0} n’active que {1}.',
"{0} doesn't enable every language in this article":
'{0} n’active pas toutes les langues de cet article',
'Enable {0} on {1} first.': 'Activez d’abord {0} sur {1}.',
'Languages disabled on {0} count too.': 'Les langues désactivées sur {0} comptent aussi.',
'Nothing was moved.': 'Rien n’a été déplacé.',
'The {0} address "{1}" is already used by another {0} article on {2}.':
'L’adresse {0} « {1} » est déjà utilisée par un autre article en {0} sur {2}.',
'An address is already used on {0}': 'Une adresse est déjà utilisée sur {0}',
'Change it before moving. Nothing was moved.':
'Changez-la avant de déplacer. Rien n’a été déplacé.',
'Moving…': 'Déplacement…',
'Moves all language versions of this article.':
'Déplace toutes les versions linguistiques de cet article.',
'Addresses and links': 'Adresses et liens',
'Each version keeps its address. Links from the old site to it will stop working. RenewCMS does not create redirects.':
'Chaque version garde son adresse. Les liens de l’ancien site vers elle cesseront de fonctionner. RenewCMS ne crée pas de redirections.',
Moving: 'Déplacement',
'This takes a few seconds.': 'Cela prend quelques secondes.',
'Moving needs edit permission on every version':
'Déplacer exige la permission de modifier chaque version',
'Ask an owner to move it. Nothing was changed.':
'Demandez à un propriétaire de le déplacer. Rien n’a été modifié.',
'Nothing was moved. Try again.': 'Rien n’a été déplacé. Réessayez.',
'Moves the article to the site you choose.':
'Déplace l’article vers le site que vous choisissez.',
'Move article…': 'Déplacer l’article…',
'Starts an article in this language.': 'Démarre un article dans cette langue.',
'Translation of an existing article': 'Traduction d’un article existant',
'Uses the same rules as Add translation.': 'Suit les mêmes règles que Ajouter une traduction.',
'{0} only offers {1}, so the language was reset from {2}.':
'{0} ne propose que {1}, la langue a donc été réinitialisée depuis {2}.',
'Default for {0}.': 'Par défaut pour {0}.',
'No {0} version yet. Copies from {1}': 'Pas encore de version en {0}. Copie depuis {1}',
'Create a translation of an article you can read.':
'Créez une traduction d’un article que vous pouvez lire.',
'Give the article a title. You can write the rest in the editor.':
'Donnez un titre à l’article. Vous écrirez le reste dans l’éditeur.',
'Create article': 'Créer l’article',
'{0} draft created': 'Brouillon en {0} créé',
'Search articles': 'Rechercher des articles',
'Choose an article': 'Choisir un article',
'No article is missing this language.': 'Aucun article ne manque dans cette langue.',
'New article or translation': 'Nouvel article ou traduction',
'{0} already exists for this article': '{0} existe déjà pour cet article',
'Open it to continue. Your entries are kept here.':
'Ouvrez-la pour continuer. Votre saisie est conservée ici.',
"{0} can't be created for this article": '{0} ne peut pas être créée pour cet article',
'Try a different language, or ask an owner. Your entries are kept.':
'Essayez une autre langue ou demandez à un propriétaire. Votre saisie est conservée.',
'{0} was disabled': '{0} a été désactivée',
'{0} was disabled on this site while you were filling this in. Ask an owner to re-enable it, or choose another language. Your entries are kept.':
'{0} a été désactivée sur ce site pendant votre saisie. Demandez à un propriétaire de la réactiver ou choisissez une autre langue. Votre saisie est conservée.',
"The source isn't available to you": 'La source n’est pas disponible pour vous',
'You can no longer read the version you chose. Choose another source you can read, or start empty. Your title is kept.':
'Vous ne pouvez plus lire la version choisie. Choisissez une autre source lisible ou commencez vide. Votre titre est conservé.',
"Couldn't create the translation": 'Impossible de créer la traduction',
'Your entries are kept. Try again.': 'Votre saisie est conservée. Réessayez.',
'Open {0} version': 'Ouvrir la version en {0}',
'Your {0} title and body are saved as a text file. Your changes are still in the editor.':
'Votre titre et votre corps en {0} sont enregistrés dans un fichier texte. Vos modifications sont toujours dans l’éditeur.',
'Your title and body are saved as a text file. Your changes are still in the editor.':
'Votre titre et votre corps sont enregistrés dans un fichier texte. Vos modifications sont toujours dans l’éditeur.',
"Couldn't delete the whole article": 'Impossible de supprimer tout l’article',
'Some versions were deleted. Open the language menu to see what is left.':
'Certaines versions ont été supprimées. Ouvrez le menu des langues pour voir ce qui reste.',
'Delete the {0} version?': 'Supprimer la version en {0} ?',
'"{0}" will be deleted. Other versions stay.':
'« {0} » sera supprimé. Les autres versions restent.',
'{0} version deleted': 'Version en {0} supprimée',
'"{0}" is gone. Other versions stay.': '« {0} » a disparu. Les autres versions restent.',
"You can't delete this article": 'Vous ne pouvez pas supprimer cet article',
'Deleting an article needs delete permission on every version. You can still delete the version you are editing.':
'Supprimer un article exige la permission de supprimer chaque version. Vous pouvez toujours supprimer la version que vous modifiez.',
'Delete the only version?': 'Supprimer l’unique version ?',
'{0} is the only version of this article. Deleting it deletes the article.':
'{0} est la seule version de cet article. La supprimer supprime l’article.',
"This deletes the article and every version you can read. It can't be undone.":
'Cela supprime l’article et chaque version que vous pouvez lire. C’est irréversible.',
'Moved to {0}': 'Déplacé vers {0}',
'This page may be out of date. Reload it to see the latest.':
'Cette page n’est peut-être plus à jour. Rechargez-la pour voir la dernière version.',
'All {0} versions moved together.': 'Les {0} versions ont été déplacées ensemble.',
'The article moved.': 'L’article a été déplacé.',
'{0} · published': '{0} · publiée',
'{0} · draft': '{0} · brouillon',
'{0} and {1} more will be copied. You can change them in the editor.':
'{0} et {1} autres seront copiés. Vous pourrez les modifier dans l’éditeur.',
'{0} will be copied. You can change them in the editor.':
'{0} seront copiés. Vous pourrez les modifier dans l’éditeur.',
'1 inline image is reused.': '1 image dans le texte est réutilisée.',
'{0} inline images are reused.': '{0} images dans le texte sont réutilisées.',
'Cover image is reused.': 'L’image de couverture est réutilisée.',
'Cover image and 1 inline image are reused.':
'L’image de couverture et 1 image dans le texte sont réutilisées.',
'Cover image and {0} inline images are reused.':
'L’image de couverture et {0} images dans le texte sont réutilisées.',
'Alternative text is copied and stays in {0} until you translate it.':
'Le texte alternatif est copié et reste en {0} jusqu’à ce que vous le traduisiez.',
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { addTranslationLocation } from '@/lib/addTranslationLocation'
describe('The list row menu leads to the Add translation dialog', () => {
it('names the language when exactly one is missing', () => {
expect(addTranslationLocation({ id: 12, missing_langs: ['de'] })).toEqual({
path: '/article/12/edit',
query: { dialog: 'add-translation', lang: 'de' },
})
})
it('names no language when several are missing', () => {
expect(addTranslationLocation({ id: 12, missing_langs: ['de', 'ar'] })).toEqual({
path: '/article/12/edit',
query: { dialog: 'add-translation' },
})
})
it('uses the language of the chip that was activated', () => {
expect(addTranslationLocation({ id: 12, missing_langs: ['de', 'ar'] }, 'ar')).toEqual({
path: '/article/12/edit',
query: { dialog: 'add-translation', lang: 'ar' },
})
})
})
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { languageAfterSiteChange } from '@/lib/languageAfterSiteChange'
describe('languageAfterSiteChange', () => {
it('keeps the language the new site still offers', () => {
expect(languageAfterSiteChange('fr', ['en', 'fr'], 'en')).toEqual({
lang: 'fr',
replaced: false,
})
})
it('falls back to the default and reports the replacement', () => {
expect(languageAfterSiteChange('de', ['en'], 'en')).toEqual({
lang: 'en',
replaced: true,
})
})
it('picks the default without a replacement when nothing was chosen', () => {
expect(languageAfterSiteChange(undefined, ['en', 'fr'], 'en')).toEqual({
lang: 'en',
replaced: false,
})
})
it('leaves the language empty when the site has no default', () => {
expect(languageAfterSiteChange('de', [], null)).toEqual({
lang: undefined,
replaced: true,
})
})
})
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { ApiError } from '@/lib/apiError'
import { classifyWithLanguages } from '@/lib/translationFailure'
import type { SiteLanguage } from '@/lib/siteLanguages'
function row(lang: string, enabled: boolean) {
return { lang, enabled } as SiteLanguage
}
const unavailable = new ApiError(409, undefined, 'language_unavailable')
describe('classifyWithLanguages', () => {
it('reads hidden while the language is still enabled', async () => {
const outcome = await classifyWithLanguages(unavailable, 'de', async () => [
row('de', true),
])
expect(outcome).toBe('hidden')
})
it('reads langOff once the language is disabled', async () => {
const outcome = await classifyWithLanguages(unavailable, 'de', async () => [
row('de', false),
])
expect(outcome).toBe('langOff')
})
it('assumes the language is still enabled when the languages cannot be read', async () => {
const outcome = await classifyWithLanguages(unavailable, 'de', async () => {
throw new Error('offline')
})
expect(outcome).toBe('hidden')
})
it('does not read the languages for other failures', async () => {
let reads = 0
const outcome = await classifyWithLanguages(new ApiError(500), 'de', async () => {
reads += 1
return []
})
expect(outcome).toBe('failed')
expect(reads).toBe(0)
})
})
+15
View File
@@ -0,0 +1,15 @@
import type { RouteLocationRaw } from 'vue-router'
export const ADD_TRANSLATION_DIALOG = 'add-translation'
export function addTranslationLocation(
item: { id: number; missing_langs?: string[] },
lang?: string,
): RouteLocationRaw {
const missing = item.missing_langs ?? []
const named = lang ?? (missing.length === 1 ? missing[0] : undefined)
return {
path: `/article/${item.id}/edit`,
query: { dialog: ADD_TRANSLATION_DIALOG, ...(named ? { lang: named } : {}) },
}
}
+20
View File
@@ -23,6 +23,14 @@ export const KNOWN_ERROR_CODES = [
'wrong_password',
'last_admin',
'account_not_deletable',
'revision_conflict',
'language_exists',
'language_unavailable',
'language_not_enabled',
'source_unavailable',
'site_not_found',
'setup_required',
'slug_conflict',
] as const
export type ApiErrorCode = (typeof KNOWN_ERROR_CODES)[number]
@@ -32,6 +40,10 @@ export interface ApiErrorDetails {
permission?: string
escalationKey?: string
lastRole?: string
existingArticleId?: number
currentRevision?: number
missingLanguages?: string[]
conflictingArticles?: number[]
}
const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.'
@@ -55,6 +67,10 @@ export class ApiError extends Error {
permission?: string
escalationKey?: string
lastRole?: string
existingArticleId?: number
currentRevision?: number
missingLanguages?: string[]
conflictingArticles?: number[]
constructor(status: number, retryAfter?: number, body?: string, details: ApiErrorDetails = {}) {
super(`Request failed with status ${status}`)
@@ -65,5 +81,9 @@ export class ApiError extends Error {
this.permission = details.permission
this.escalationKey = details.escalationKey
this.lastRole = details.lastRole
this.existingArticleId = details.existingArticleId
this.currentRevision = details.currentRevision
this.missingLanguages = details.missingLanguages
this.conflictingArticles = details.conflictingArticles
}
}
+2
View File
@@ -23,9 +23,11 @@ export interface Article extends Partial<ArticleSeo> {
id: number
title: string
body: string
images?: { id: number }[]
is_online: boolean
lang?: string
story_id?: number
revision?: number
capabilities: ArticleCapability[]
translations?: CoverageEntry[]
site_id?: number
+16
View File
@@ -0,0 +1,16 @@
const HTML_MIME = 'text/html'
const BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, pre'
const BLANK_LINE = '\n\n'
export function bodyPlainText(html: string) {
const document = new DOMParser().parseFromString(html, HTML_MIME)
const blocks = Array.from(document.body.querySelectorAll(BLOCK_SELECTOR))
.filter((block) => !block.querySelector(BLOCK_SELECTOR))
.map((block) => (block.textContent ?? '').trim())
.filter((text) => text !== '')
return blocks.length > 0 ? blocks.join(BLANK_LINE) : (document.body.textContent ?? '').trim()
}
export function conflictDownloadText(title: string, html: string) {
return `${title}${BLANK_LINE}${bodyPlainText(html)}\n`
}
+5
View File
@@ -0,0 +1,5 @@
export function clockOf(date: Date) {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
+1 -6
View File
@@ -1,4 +1,5 @@
import { t } from '@/i18n'
import { clockOf } from '@/lib/clockTime'
import type { Notice } from '@/lib/editorActions'
export interface EditorNotice {
@@ -20,12 +21,6 @@ export interface NoticeInput {
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',
+11 -4
View File
@@ -57,8 +57,8 @@ 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 blockedExtra(blocked: boolean, reason: string): Partial<OverflowItem> {
return blocked ? { disabled: true, reason } : {}
}
function editItems(context: OverflowContext): OverflowItem[] {
@@ -70,12 +70,19 @@ function editItems(context: OverflowContext): OverflowItem[] {
t('Move article'),
'i-lucide-arrow-right-left',
context,
blockedExtra(blocked),
blockedExtra(blocked, t("You can't delete this article.")),
),
]
}
function deleteItems(context: OverflowContext): OverflowItem[] {
if (context.language && context.siblings.length === 0) {
return [
item('deleteArticle', t('Delete article'), 'i-lucide-trash-2', context, {
danger: true,
}),
]
}
const versionItem = item(
'deleteVersion',
deleteVersionLabel(context.language),
@@ -91,7 +98,7 @@ function deleteItems(context: OverflowContext): OverflowItem[] {
versionItem,
item('deleteArticle', t('Delete article'), 'i-lucide-trash-2', context, {
danger: !blocked,
...blockedExtra(blocked),
...blockedExtra(blocked, t("You can't delete this article.")),
}),
]
}
+15
View File
@@ -0,0 +1,15 @@
export interface LanguageAfterSiteChange {
lang: string | undefined
replaced: boolean
}
export function languageAfterSiteChange(
previous: string | undefined,
offered: string[],
defaultLang: string | null,
): LanguageAfterSiteChange {
if (previous !== undefined && offered.includes(previous)) {
return { lang: previous, replaced: false }
}
return { lang: defaultLang ?? undefined, replaced: previous !== undefined }
}
+27
View File
@@ -0,0 +1,27 @@
import { t } from '@/i18n'
import type { CoverageEntry } from '@/lib/articleTypes'
import { holds } from '@/lib/editorCapabilities'
import { nameInSentence } from '@/lib/storyList'
export interface MoveVersion {
name: string
status: string
}
export type MoveResult =
| { kind: 'moved' }
| { kind: 'languages'; missing: string[]; enabled: string[] | null }
| { kind: 'slug'; conflicts: { language: string; slug: string }[] }
| { kind: 'perm' }
| { kind: 'failed' }
| { kind: 'gone' }
export function moveVersions(entries: CoverageEntry[]): MoveVersion[] {
return entries.map((entry) => ({
name: nameInSentence(entry.lang),
status: [
entry.is_online ? t('Published') : t('Draft'),
...(holds(entry.capabilities, 'edit') ? [] : [t('Read only')]),
].join(' · '),
}))
}
+2
View File
@@ -6,6 +6,7 @@ export interface StoredDraft {
body?: string
meta?: { site_id: number | null; authors: Byline[] }
seo?: ArticleSeo
revision?: number
mirrored_at: string
}
@@ -73,6 +74,7 @@ function isStoredDraft(value: unknown): value is StoredDraft {
isOptionalString(value.body) &&
isMeta(value.meta) &&
isSeo(value.seo) &&
(value.revision === undefined || typeof value.revision === 'number') &&
isMirrorTime(value.mirrored_at)
)
}
+60
View File
@@ -0,0 +1,60 @@
import { ApiError } from '@/lib/apiError'
import { codeOf, statusOf } from '@/lib/directoryErrors'
import type { SiteLanguage } from '@/lib/siteLanguages'
import { enabledLanguages } from '@/lib/translationSource'
export type TranslationFailure =
| 'exists'
| 'hidden'
| 'langOff'
| 'unreadable'
| 'failed'
| 'gone'
| 'session'
const NOT_FOUND_ARTICLE = 'Article not found'
export async function classifyTranslationFailure(
error: unknown,
languageStillEnabled: () => Promise<boolean>,
): Promise<TranslationFailure> {
const status = statusOf(error)
const code = codeOf(error)
if (status === 401) {
return 'session'
}
if (status === 409 && code === 'language_exists') {
return 'exists'
}
if (status === 409 && code === 'language_unavailable') {
return (await languageStillEnabled()) ? 'hidden' : 'langOff'
}
if (status === 422 && code === 'language_not_enabled') {
return 'langOff'
}
if (status === 404 && code === 'source_unavailable') {
return 'unreadable'
}
if (status === 404 && code === NOT_FOUND_ARTICLE) {
return 'gone'
}
return 'failed'
}
export function classifyWithLanguages(
error: unknown,
lang: string | undefined,
loadLanguages: () => Promise<SiteLanguage[]>,
) {
return classifyTranslationFailure(error, async () => {
try {
return enabledLanguages(await loadLanguages()).some((entry) => entry.lang === lang)
} catch {
return true
}
})
}
export function existingArticleOf(error: unknown) {
return error instanceof ApiError ? error.existingArticleId : undefined
}
+68
View File
@@ -0,0 +1,68 @@
import { locale, t } from '@/i18n'
import type { Article, CoverageEntry } from '@/lib/articleTypes'
import { joinNames } from '@/lib/nameList'
import { languageName, type SiteLanguage } from '@/lib/siteLanguages'
const NAMED_AUTHORS = 5
const MULTIPLE = 2
export function enabledLanguages(languages: SiteLanguage[]) {
return languages.filter((language) => language.enabled)
}
export function offeredLanguages(languages: SiteLanguage[], readable: CoverageEntry[]) {
const present = new Set(readable.map((entry) => entry.lang))
return enabledLanguages(languages)
.map((language) => language.lang)
.filter((lang) => !present.has(lang))
}
export function defaultSource(
languages: SiteLanguage[],
readable: CoverageEntry[],
): CoverageEntry | undefined {
const preferred = languages.find((language) => language.is_default)?.lang
return readable.find((entry) => entry.lang === preferred) ?? readable[0]
}
export function sourceLabel(entry: CoverageEntry) {
const name = languageName(entry.lang, locale.value)
return entry.is_online ? t('{0} · published', [name]) : t('{0} · draft', [name])
}
export function authorsNote(authors: { name: string }[]) {
if (authors.length < MULTIPLE) {
return null
}
const named = authors.slice(0, NAMED_AUTHORS).map((author) => author.name)
const rest = authors.length - named.length
return rest > 0
? t('{0} and {1} more will be copied. You can change them in the editor.', [
named.join(', '),
rest,
])
: t('{0} will be copied. You can change them in the editor.', [joinNames(named)])
}
export function imagesNote(source: Pick<Article, 'images' | 'cover_image_id'>, language: string) {
const ids = new Set((source.images ?? []).map((image) => image.id))
const cover = source.cover_image_id ?? null
if (cover !== null) {
ids.delete(cover)
}
const inline = ids.size
if (cover === null && inline === 0) {
return null
}
const reused =
cover === null
? inline === 1
? t('1 inline image is reused.')
: t('{0} inline images are reused.', [inline])
: inline === 0
? t('Cover image is reused.')
: inline === 1
? t('Cover image and 1 inline image are reused.')
: t('Cover image and {0} inline images are reused.', [inline])
return `${reused} ${t('Alternative text is copied and stays in {0} until you translate it.', [language])}`
}
+13 -4
View File
@@ -18,6 +18,7 @@ import EmptyState from '@/components/EmptyState.vue'
import OwnScopeEmpty from '@/components/OwnScopeEmpty.vue'
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import { addTranslationLocation } from '@/lib/addTranslationLocation'
import AppPaginator from '@/components/AppPaginator.vue'
import { useArticleFilterOptions } from '@/composables/useArticleFilterOptions'
import { useArticleList } from '@/composables/useArticleList'
@@ -25,7 +26,7 @@ import { useArticleRowActions } from '@/composables/useArticleRowActions'
import { useArticleSites } from '@/composables/useArticleSites'
import { useContentAccess } from '@/composables/useContentAccess'
import { useIsDesktop } from '@/composables/useIsDesktop'
import type { Article } from '@/composables/useArticles'
import type { Article, StoryItem } from '@/composables/useArticles'
import { effectiveSort } from '@/lib/articleListQuery'
import { filterChips, type FilterChipKey } from '@/lib/articleFilterChips'
import type { ArticleFilterFieldProps } from '@/lib/articleFilterFields'
@@ -150,8 +151,8 @@ function openVersion(id: number) {
router.push(`/article/${id}/edit`)
}
function addTranslation(item: Article) {
openVersion(item.id)
function addTranslation(item: StoryItem, lang?: string) {
router.push(addTranslationLocation(item, lang))
}
const menuHandlers: StoryMenuHandlers = {
@@ -167,6 +168,10 @@ function openNewArticleModal() {
newArticleModalOpen.value = true
}
function openExistingVersion(id: number) {
router.push(`/article/${id}/edit`)
}
function onArticleCreated(article: Article) {
router.push(`/article/${article.id}/edit`)
}
@@ -378,6 +383,10 @@ onMounted(() => {
@clear="list.clearFilters"
/>
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
<NewArticleModal
v-model:open="newArticleModalOpen"
@created="onArticleCreated"
@open="openExistingVersion"
/>
</DashboardShell>
</template>
+155 -12
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { createReusableTemplate } from '@vueuse/core'
import { useRoute, useRouter } from 'vue-router'
import { useLocalizedToast } from '@/i18n/presentation'
@@ -33,13 +33,18 @@ import { useArticleDraft, type SaveOutcome } from '@/composables/useArticleDraft
import { useArticleLifecycle } from '@/composables/useArticleLifecycle'
import { useEditorPublishing } from '@/composables/useEditorPublishing'
import { useEditorVersions } from '@/composables/useEditorVersions'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useEditorConflict } from '@/composables/useEditorConflict'
import { useEditorDeletion } from '@/composables/useEditorDeletion'
import { announce } from '@/composables/useLiveAnnouncement'
import { useEditorMove } from '@/composables/useEditorMove'
import { useEditorTranslation } from '@/composables/useEditorTranslation'
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 { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useSites, type Site } from '@/composables/useSites'
import { toBylines } from '@/lib/articleMeta'
import { readMetaSectionOpen, storeMetaSectionOpen } from '@/lib/metaSectionMemory'
@@ -50,6 +55,7 @@ import { bodyCounts } from '@/lib/bodyCounts'
import { useCanManageVariants } from '@/composables/useCanManageVariants'
import { planActions, type SaveMood } from '@/lib/editorActions'
import { holds } from '@/lib/editorCapabilities'
import type { VersionRow } from '@/lib/editorVersions'
import { titleLabel } from '@/lib/editorLabels'
import { editorNotices } from '@/lib/editorNotices'
import { overflowItems } from '@/lib/editorOverflow'
@@ -188,7 +194,17 @@ const address = computed(
() => `${hostOf(siteOfArticle.value?.base_url ?? '')}/${addressSlug.value}`,
)
let ignoreEditorEcho = false
function ignoreNextEditorEcho() {
ignoreEditorEcho = true
nextTick(() => nextTick(() => (ignoreEditorEcho = false)))
}
function editBody(newBody: string) {
if (ignoreEditorEcho) {
return
}
draft.edit(newBody)
publishing.clearPublishFailure()
check.schedule()
@@ -229,6 +245,7 @@ async function loadArticle() {
},
seoOf(result),
result.title,
result.revision,
)
await versions.loadLanguages()
loadState.value = 'loaded'
@@ -313,7 +330,9 @@ const overflow = computed(() =>
unpublishInMenu: plan.value.unpublish && !wideScreen.value,
handlers: {
unpublish: handleUnpublish,
deleteVersion: handleDelete,
deleteVersion: deletion.deleteVersion,
deleteArticle: deletion.deleteArticle,
move: movement.open,
},
}),
)
@@ -337,9 +356,23 @@ function announceSaved() {
}))
}
async function runSave(options: { announce: boolean } = { announce: true }): Promise<SaveOutcome> {
interface SaveRun {
announce: boolean
revision?: number
conflictDialog?: boolean
}
async function runSave(options: SaveRun = { announce: true }): Promise<SaveOutcome> {
const hadTitleEdit = draft.title.value !== article.value?.title
const outcome = await draft.save()
const outcome = await draft.save({ revision: options.revision })
if (outcome === 'conflict' && options.conflictDialog !== false) {
if (await conflict.begin()) {
return outcome
}
draft.markFailed()
titleSaveFailed.value = hadTitleEdit
return 'error'
}
if (outcome === 'saved') {
titleSaveFailed.value = false
syncSavedTitle()
@@ -351,10 +384,6 @@ async function runSave(options: { announce: boolean } = { announce: true }): Pro
}
titleSaveFailed.value = outcome === 'error' && hadTitleEdit
revealSaveFailure(outcome)
if (outcome === 'site-gone') {
directoryToasts.siteGone()
sites.value = await fetchSites()
}
if (outcome === 'author-gone') {
directoryToasts.authorGone()
}
@@ -450,6 +479,120 @@ const { choose: chooseVersion, loadingVisible: switching } = useVersionSwitch({
},
})
const REFETCH_ATTEMPTS = 2
async function fetchFreshVersion() {
for (let attempt = 1; attempt <= REFETCH_ATTEMPTS; attempt += 1) {
try {
return await request<Article>(`/v1/article/${articleId}?format=html`)
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
markNotFound()
return null
}
}
}
announce(t('This page may be out of date. Reload it to see the latest.'))
return null
}
async function refreshOpenVersion() {
if (!article.value) {
return
}
const fresh = await fetchFreshVersion()
const current = article.value
if (!fresh || !current) {
return
}
article.value = {
...current,
site_id: fresh.site_id,
revision: fresh.revision,
updated_at: fresh.updated_at,
translations: fresh.translations,
capabilities: fresh.capabilities,
}
draft.setBaseRevision(fresh.revision)
draft.syncSite(fresh.site_id ?? null)
await versions.loadLanguages().catch(() => undefined)
}
function applyTheirs(theirs: Article) {
const current = article.value
ignoreNextEditorEcho()
draft.replaceWith(theirs)
if (current) {
article.value = { ...current, ...theirs, translations: current.translations }
versions.markTitle(current.id, theirs.title)
}
check.invalidate()
check.runFresh()
}
const conflict = useEditorConflict({
articleId,
draft,
article,
language: () => versions.sentenceName.value,
saveOver: (revision) => runSave({ announce: true, revision, conflictDialog: false }),
onReloaded: applyTheirs,
onGone: markNotFound,
onClosed: (result) => {
if (result === 'gone') {
markNotFound()
return
}
titleField.value?.focus()
},
})
const deletion = useEditorDeletion({
article,
readable: () => versions.orderedReadable.value,
language: () => versions.sentenceName.value,
classicDelete: handleClassicDelete,
refetch: refreshOpenVersion,
onDeleted: () => {
leaving.value = true
draft.discardStored()
},
})
const movement = useEditorMove({
article,
sites,
readable: () => versions.orderedReadable.value,
multilingual: () => versions.multilingual.value,
onMoved: refreshOpenVersion,
onGone: markNotFound,
refreshSites: async () => {
sites.value = await fetchSites()
},
})
const translation = useEditorTranslation({
article,
loaded: computed(() => loadState.value === 'loaded'),
languages: () => versions.siteLanguages.value,
refetchLanguages: versions.refreshLanguages,
switchTo: (row, announcement) => chooseVersion(row, announcement),
onGone: markNotFound,
})
function openMoveFromDetails() {
detailsOpen.value = false
movement.open()
}
function onChooseRow(row: VersionRow) {
if (row.addTranslation) {
translation.open(row.lang)
return
}
chooseVersion(row)
}
function handlePublish() {
return versions.multilingual.value ? publishing.publish() : setOnline(true)
}
@@ -477,7 +620,7 @@ function handlePrimary() {
return undefined
}
async function handleDelete() {
async function handleClassicDelete() {
const current = article.value
if (!current) {
return
@@ -549,7 +692,7 @@ useUnsavedChangesGuard({
:authors="draft.meta.value.authors"
:remembered="remembered"
:sheet="sheet"
@update:site-id="draft.editMeta({ siteId: $event })"
@move="openMoveFromDetails"
@update:authors="draft.editMeta({ authors: $event })"
@update:remembered="rememberMetaSection"
/>
@@ -600,7 +743,7 @@ useUnsavedChangesGuard({
:rows="versions.rows.value"
:name="versions.displayName.value"
:code="versions.tag.value.toUpperCase()"
@choose="chooseVersion"
@choose="onChooseRow"
/>
<LocaleBadge
:tone="article.is_online ? 'success' : 'neutral'"
+9 -1
View File
@@ -122,6 +122,10 @@ function openNewArticleModal() {
newArticleModalOpen.value = true
}
function openExistingVersion(id: number) {
router.push(`/article/${id}/edit`)
}
function onArticleCreated(article: Article) {
router.push(`/article/${article.id}/edit`)
toast.add(() => ({
@@ -369,6 +373,10 @@ onMounted(loadArticles)
</div>
</div>
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
<NewArticleModal
v-model:open="newArticleModalOpen"
@created="onArticleCreated"
@open="openExistingVersion"
/>
</DashboardShell>
</template>
@@ -455,6 +455,21 @@ describe('loc.l7.rowMenu, Row menu', () => {
expect(menuLabels()).toEqual(expected)
})
it('opens the editor with the Add translation dialog and no language when several are missing', async () => {
await mountMeta('articles', '/article?site=journal&size=5', { backend: backendFor() })
await openMenu('Winter checklist for timber windows')
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((item) => textOf(item).startsWith('Add translation'))!
.click()
await flushPromises()
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toMatch(/^\/article\/\d+\/edit$/),
)
expect(router.currentRoute.value.query.dialog).toBe('add-translation')
expect(router.currentRoute.value.query.lang).toBeUndefined()
})
it('hides Add translation on an article that has every language', async () => {
await mountMeta('articles', '/article?site=journal&size=5', { backend: backendFor() })
await openMenu('How we price a bathroom renovation')
@@ -0,0 +1,544 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { initializeLocale } from '@/i18n'
import LocaleSelect from '@/components/localization/LocaleSelect.vue'
import { NetworkError } from '@/composables/useApi'
import {
callsTo,
deferred,
dialogBox,
dialogText,
failWith,
kit,
press,
settle,
typeInto,
unmountRoute,
} from './localizationKit'
import { EDIT_ONLY, IDS, articleOf, mountEditor, type EditorFixture } from './editorKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const DIALOG = '/article/12/edit?dialog=add-translation'
const CREATED_ID = 14
const FOUR = ['en', 'fr', 'ar'] as const
beforeEach(() => {
initializeLocale()
sessionStorage.clear()
})
afterEach(unmountRoute)
function created() {
return {
...articleOf({ lang: 'fr' }, 'de'),
id: CREATED_ID,
title: 'Kosten',
is_online: false,
revision: 1,
}
}
function open(fixture: EditorFixture = {}, url = `${DIALOG}&lang=de`) {
return mountEditor(
{
translations: [...FOUR],
...fixture,
extra: {
'POST /v1/article/12/translations': created(),
'GET /v1/article/14': created(),
...fixture.extra,
} as never,
},
url,
)
}
function field(label: string) {
return kit
.wrapper!.findAllComponents(LocaleSelect)
.find((component) => component.props('ariaLabel') === label)
}
function dialogTitleInput() {
return dialogBox()!.querySelector<HTMLInputElement>('input[type="text"], input:not([type])')!
}
async function submit(label = 'Create German draft') {
await press(label, dialogBox()!)
}
async function fillTitle(value = 'Kosten') {
await typeInto(dialogTitleInput(), value)
}
describe('loc.l4.form: The dialog opens from the URL and from the language menu', () => {
it('opens for German with the title field focused', async () => {
await open()
expect(dialogText()).toContain('Add a translation')
expect(field('Language')!.props('modelValue')).toBe('de')
expect(document.activeElement).toBe(dialogTitleInput())
})
it('opens with Language empty and the primary disabled when no language is given', async () => {
await open({}, DIALOG)
expect(field('Language')!.props('modelValue')).toBeUndefined()
const primary = Array.from(dialogBox()!.querySelectorAll('button')).find((button) =>
/Create/.test(button.textContent ?? ''),
)!
expect(primary.hasAttribute('disabled')).toBe(true)
})
it.each([
['French, because a version exists', 'fr'],
['Spanish, because it is disabled', 'es'],
])('leaves Language empty for %s', async (_name, lang) => {
await open({}, `${DIALOG}&lang=${lang}`)
expect(field('Language')!.props('modelValue')).toBeUndefined()
})
it('opens no dialog and strips the parameters without create_translation', async () => {
await open({ caps: EDIT_ONLY })
expect(dialogBox()).toBeNull()
expect(router.currentRoute.value.fullPath).toBe('/article/12/edit')
})
it('closes with the Back button', async () => {
await open({}, '/article/12/edit')
await router.push(`${DIALOG}&lang=de`)
await settle()
expect(dialogBox()).not.toBeNull()
router.back()
await new Promise((resolve) => setTimeout(resolve, 50))
await settle()
expect(dialogBox()).toBeNull()
})
})
describe('Offered languages are the enabled languages with no readable version', () => {
it('lists German only, in the site order', async () => {
await open()
expect(field('Language')!.props('items')).toEqual([{ label: 'German', value: 'de' }])
})
it('lists a language whose version is unreadable to the caller too', async () => {
await open({ translations: ['en', 'fr'] }, `${DIALOG}&lang=de`)
expect(field('Language')!.props('items')).toEqual([
{ label: 'German', value: 'de' },
{ label: 'Arabic', value: 'ar' },
])
})
})
describe('The form copy, source and Start empty', () => {
it('describes the copy and offers the board fields and footer', async () => {
await open()
const text = dialogText()
expect(text).toContain(
'Creates a German draft of "Couts de renovation". It isn\'t published.',
)
expect(text).toContain('Title in German')
expect(text).toContain(
'Copying text is a starting point: it stays in English until you translate it.',
)
expect(text).toContain('Start empty')
expect(text).toContain('Copy from English')
expect(text).toContain(
"Copies body, excerpt, search and social text, image alternative text and authors. Images are reused, files aren't duplicated. The version starts as Needs review.",
)
const footer = Array.from(dialogBox()!.querySelectorAll('button')).map((button) =>
button.textContent?.trim(),
)
expect(footer.slice(-2)).toEqual(['Cancel', 'Create German draft'])
})
it('picks the default-language version as the source and labels the options', async () => {
await open()
expect(field('Source version')!.props('modelValue')).toBe(String(IDS.en))
expect(field('Source version')!.props('items')).toEqual([
{ label: 'English · published', value: '10' },
{ label: 'French · draft', value: '12' },
{ label: 'Arabic · published', value: '13' },
])
})
it('hides the source and the copy note on Start empty', async () => {
await open()
kit.wrapper!.findComponent({ name: 'LocaleChoiceCards' }).vm.$emit(
'update:modelValue',
'empty',
)
await settle()
expect(field('Source version')).toBeUndefined()
expect(dialogText()).not.toContain('Copying text is a starting point')
expect(dialogText()).toContain(
'Only the title. Freshness shows No source until you pick a tracked source.',
)
})
})
describe('Title validation runs before any request', () => {
it.each([
['nothing', '', 'Enter a title'],
['"ab"', 'ab', 'Use at least 3 characters'],
['201 characters', 'x'.repeat(201), 'Use 200 characters or fewer'],
])('shows the message for %s and sends nothing', async (_name, title, message) => {
await open()
await fillTitle(title)
await submit()
expect(dialogText()).toContain(message)
expect(callsTo('POST', '/v1/article/12/translations')).toHaveLength(0)
})
it('sends the request for 200 emoji', async () => {
await open()
await fillTitle('😀'.repeat(200))
await submit()
expect(callsTo('POST', '/v1/article/12/translations')).toHaveLength(1)
})
})
describe('Creating the translation', () => {
it('copies from the chosen source with the open version in the path', async () => {
await open()
field('Source version')!.vm.$emit('update:modelValue', String(IDS.fr))
await settle()
await fillTitle()
await submit()
expect(callsTo('POST', '/v1/article/12/translations')[0]!.body).toEqual({
lang: 'de',
title: 'Kosten',
copy: true,
source_article_id: 12,
})
})
it('sends no source when starting empty', async () => {
await open()
kit.wrapper!.findComponent({ name: 'LocaleChoiceCards' }).vm.$emit(
'update:modelValue',
'empty',
)
await settle()
await fillTitle()
await submit()
expect(callsTo('POST', '/v1/article/12/translations')[0]!.body).toEqual({
lang: 'de',
title: 'Kosten',
copy: false,
})
})
it('locks the dialog while creating: Creating…, disabled Cancel, Escape does nothing', async () => {
const gate = deferred<unknown>()
await open({ extra: { 'POST /v1/article/12/translations': () => gate.promise } as never })
await fillTitle()
await submit()
const labels = Array.from(dialogBox()!.querySelectorAll('button')).map((button) => ({
label: button.textContent?.trim(),
disabled: button.hasAttribute('disabled'),
}))
expect(labels).toContainEqual({ label: 'Cancel', disabled: true })
expect(labels.some((entry) => entry.label === 'Creating…')).toBe(true)
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await settle()
expect(dialogBox()).not.toBeNull()
gate.resolve(created())
await settle()
})
it('opens the new version, announces it and lists it in the menu', async () => {
await open()
await fillTitle()
await submit()
expect(dialogBox()).toBeNull()
expect(router.currentRoute.value.path).toBe('/article/14/edit')
expect(document.body.querySelector('[data-testid="live-announcement"]')?.textContent).toBe(
'German draft created',
)
})
it('asks before leaving unsaved edits and keeps the open version on Keep editing', async () => {
await open()
await fillTitle()
kit.wrapper!.findComponent({ name: 'ArticleEditor' }).vm.$emit(
'update:modelValue',
'<p>Edit</p>',
)
await settle()
await submit()
expect(dialogText()).toContain('Leave without saving?')
expect(dialogText()).toContain('Discard and open German')
expect(dialogText()).toContain('Save and open German')
await press('Keep editing')
expect(router.currentRoute.value.path).toBe('/article/12/edit')
})
})
describe('Failures keep the entries', () => {
const exists = failWith(409, 'language_exists', { existingArticleId: 11 })
const unavailable = failWith(409, 'language_unavailable') as () => never
async function fail(response: unknown, languages?: EditorFixture['languages']) {
await open({
languages,
extra: { 'POST /v1/article/12/translations': response } as never,
})
await fillTitle()
await submit()
}
function primaryDisabled() {
return Array.from(dialogBox()!.querySelectorAll('button'))
.find((button) => /^Create German draft$/.test(button.textContent?.trim() ?? ''))!
.hasAttribute('disabled')
}
it('loc.dup.readable: offers Open German version and disables the primary', async () => {
await fail(exists)
expect(dialogText()).toContain('German already exists for this article')
expect(dialogText()).toContain('Open it to continue. Your entries are kept here.')
expect(dialogText()).toContain('Open German version')
expect(primaryDisabled()).toBe(true)
expect(dialogTitleInput().value).toBe('Kosten')
})
it('opens the existing version through the version switch', async () => {
await fail(exists)
kit.calls.length = 0
await press('Open German version')
expect(dialogBox()).toBeNull()
expect(router.currentRoute.value.path).toBe('/article/11/edit')
})
it('loc.dup.hidden: a neutral error with no Open action while German is still enabled', async () => {
await fail(unavailable)
expect(dialogText()).toContain("German can't be created for this article")
expect(dialogText()).toContain(
'Try a different language, or ask an owner. Your entries are kept.',
)
expect(dialogText()).not.toContain('Open German version')
expect(primaryDisabled()).toBe(true)
})
it('loc.l4.langOff: German no longer enabled makes Language read-only with the warning', async () => {
let reads = 0
let refused = false
await open({
extra: {
'POST /v1/article/12/translations': () => {
refused = true
return unavailable()
},
'GET /v1/site/journal/languages': () => {
reads += 1
return {
revision: 'r',
default: 'en',
needs_setup: false,
available: [],
languages: (refused ? ['en', 'fr', 'ar'] : ['en', 'fr', 'de', 'ar']).map(
(lang) => ({
lang,
enabled: true,
is_default: lang === 'en',
version_count: 1,
published_count: 1,
social_locale: null,
social_locale_state: 'not_set',
}),
),
}
},
} as never,
})
const before = reads
await fillTitle()
await submit()
expect(reads).toBe(before + 1)
expect(dialogText()).toContain('German was disabled')
expect(dialogText()).toContain('German was disabled on this site.')
expect(primaryDisabled()).toBe(true)
})
it('loc.l4.langOff on a 422 language_not_enabled', async () => {
await fail(failWith(422, 'language_not_enabled'))
expect(dialogText()).toContain(
'German was disabled on this site while you were filling this in',
)
})
it('loc.l4.unreadable: warns and asks for another source on a 404 source_unavailable', async () => {
await fail(failWith(404, 'source_unavailable'))
expect(dialogText()).toContain("The source isn't available to you")
expect(dialogText()).toContain('Choose a version')
expect(primaryDisabled()).toBe(true)
expect(dialogTitleInput().value).toBe('Kosten')
})
it.each([
['a 500', failWith(500, 'Internal server error')],
[
'a network failure',
() => {
throw new NetworkError()
},
],
['a 403', failWith(403, 'missing_permission')],
['a 400', failWith(400, 'The request cannot be processed')],
])('loc.l4.fail: keeps the entries and offers Try again after %s', async (_name, response) => {
await fail(response)
expect(dialogText()).toContain("Couldn't create the translation")
expect(dialogText()).toContain('Your entries are kept. Try again.')
expect(dialogText()).toContain('Try again')
expect(dialogTitleInput().value).toBe('Kosten')
})
it('shows the same DOM for the three neutral 409 answers', async () => {
await fail(unavailable)
const first = dialogBox()!.innerHTML.replace(/id="[^"]*"/g, '')
await unmountRoute()
await fail(failWith(409, 'language_unavailable', { existingArticleId: undefined }))
const second = dialogBox()!.innerHTML.replace(/id="[^"]*"/g, '')
expect(second).toBe(first)
})
it('closes the dialog and shows Version unavailable when the open version disappears', async () => {
await fail(failWith(404, 'Article not found'))
expect(dialogBox()).toBeNull()
expect(document.body.textContent).toContain("This version isn't available")
})
})
describe('Recovering from a blocking state', () => {
it('re-enables the primary after choosing another readable source', async () => {
await open({
extra: {
'POST /v1/article/12/translations': failWith(404, 'source_unavailable'),
} as never,
})
await fillTitle()
await submit()
field('Source version')!.vm.$emit('update:modelValue', String(IDS.fr))
await settle()
expect(dialogText()).not.toContain("The source isn't available to you")
})
it('re-enables the primary after choosing Start empty', async () => {
await open({
extra: {
'POST /v1/article/12/translations': failWith(404, 'source_unavailable'),
} as never,
})
await fillTitle()
await submit()
kit.wrapper!.findComponent({ name: 'LocaleChoiceCards' }).vm.$emit(
'update:modelValue',
'empty',
)
await settle()
expect(dialogText()).not.toContain("The source isn't available to you")
})
})
describe('The dialog is keyboard operable', () => {
it('closes on Escape, removes the parameters from the URL', async () => {
await open()
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await settle()
expect(dialogBox()).toBeNull()
expect(router.currentRoute.value.fullPath).toBe('/article/12/edit')
})
})
describe('loc.l4.long: Long content shows what is copied', () => {
function source(authors: number, images: number) {
return {
...articleOf({}, 'en'),
authors: Array.from({ length: authors }, (_, index) => ({
id: index + 1,
name: `Author ${index + 1}`,
slug: `author-${index + 1}`,
})),
cover_image_id: images > 0 ? 1 : null,
images: Array.from({ length: images }, (_, index) => ({ id: index + 1 })),
}
}
it('shows no Authors or Images note for one author and no image', async () => {
await open({ extra: { 'GET /v1/article/10': source(1, 0) } as never })
expect(dialogText()).not.toContain('Authors')
expect(dialogText()).not.toContain('Cover image')
expect(dialogText()).not.toContain('inline image')
})
it('names the first five authors and counts the rest, and counts the images', async () => {
await open({ extra: { 'GET /v1/article/10': source(10, 8) } as never })
expect(dialogText()).toContain(
'Author 1, Author 2, Author 3, Author 4, Author 5 and 5 more will be copied. You can change them in the editor.',
)
expect(dialogText()).toContain(
'Cover image and 7 inline images are reused. Alternative text is copied and stays in English until you translate it.',
)
})
})
describe('A 401 is handled by the global handler', () => {
it('goes to login and keeps nothing about the dialog', async () => {
await open({ extra: { 'POST /v1/article/12/translations': failWith(401) } as never })
await fillTitle()
await submit()
expect(sessionStorage.length).toBe(0)
})
})
@@ -0,0 +1,313 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { initializeLocale } from '@/i18n'
import { ApiError, NetworkError } from '@/composables/useApi'
import {
callsTo,
dialogBox,
dialogText,
failWith,
kit,
press,
settle,
text,
toastText,
unmountRoute,
} from './localizationKit'
import {
READ,
articleOf,
editBody,
mountEditor,
openMoreActions,
type EditorFixture,
} from './editorKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const ALL = ['en', 'fr', 'de', 'ar'] as const
const gone = null
beforeEach(() => {
initializeLocale()
sessionStorage.clear()
})
afterEach(unmountRoute)
function menuItem(label: string) {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(
(item) => item.textContent?.trim().startsWith(label),
)
}
function deletes() {
return kit.calls.filter((call) => call.method === 'DELETE').map((call) => call.path)
}
function everyDelete(handler: unknown = gone): Record<string, unknown> {
return {
'DELETE /v1/article/10': handler,
'DELETE /v1/article/11': handler,
'DELETE /v1/article/12': handler,
'DELETE /v1/article/13': handler,
}
}
function open(fixture: EditorFixture = {}, extra: Record<string, unknown> = {}) {
return mountEditor({
translations: [...ALL],
...fixture,
extra: { ...everyDelete(), ...extra } as never,
})
}
async function choose(label: string) {
await openMoreActions()
menuItem(label)!.click()
await settle()
}
describe('Delete scopes: the menu follows what the owner reads', () => {
it('enables both items when four versions are readable', async () => {
await open()
await openMoreActions()
expect(menuItem('Delete French version')!.getAttribute('aria-disabled')).toBeNull()
expect(menuItem('Delete article')!.getAttribute('aria-disabled')).toBeNull()
})
it('shows Delete article alone when only English is readable', async () => {
await open({ lang: 'en', translations: ['en'] })
await openMoreActions()
expect(menuItem('Delete article')!.getAttribute('aria-disabled')).toBeNull()
expect(menuItem('Delete English version')).toBeUndefined()
})
it('disables Delete article with the reason when 13 cannot be deleted', async () => {
await open({ siblingCaps: { ar: READ } })
await openMoreActions()
expect(menuItem('Delete French version')!.getAttribute('aria-disabled')).toBeNull()
expect(menuItem('Delete article')!.getAttribute('aria-disabled')).toBe('true')
expect(menuItem('Delete article')!.textContent).toContain("You can't delete this article.")
})
})
describe('loc.l8.delete: Delete French version', () => {
it('asks with a danger button and deletes only this version', async () => {
await open()
await choose('Delete French version')
expect(dialogText()).toContain('Delete the French version?')
expect(dialogText()).toContain(
'"Couts de renovation" will be deleted. Other versions stay.',
)
const buttons = Array.from(dialogBox()!.querySelectorAll('button')).map((button) =>
button.textContent?.trim(),
)
expect(buttons.slice(-2)).toEqual(['Cancel', 'Delete French version'])
await press('Delete French version', dialogBox()!)
expect(deletes()).toEqual(['/v1/article/12'])
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
expect(toastText()).toContain('French version deleted')
expect(toastText()).toContain('"Couts de renovation" is gone. Other versions stay.')
})
it('leaves a dirty draft without asking and discards the stored copy', async () => {
await open()
await editBody('<p>Edits</p>')
await choose('Delete French version')
await press('Delete French version', dialogBox()!)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
expect(Object.keys(sessionStorage)).toEqual([])
})
it('closes quietly and goes to the list when the version is already gone', async () => {
await open({}, { 'DELETE /v1/article/12': failWith(404, 'Article not found') })
await choose('Delete French version')
await press('Delete French version', dialogBox()!)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
expect(toastText()).not.toContain("Couldn't")
})
})
describe('loc.l8.deleteLast: Delete the only version', () => {
it('names the language and deletes the article', async () => {
await open({ lang: 'en', translations: ['en'] })
await choose('Delete article')
expect(dialogText()).toContain('Delete the only version?')
expect(dialogText()).toContain(
'English is the only version of this article. Deleting it deletes the article.',
)
await press('Delete article', dialogBox()!)
expect(deletes()).toEqual(['/v1/article/10'])
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
expect(toastText()).toContain('Article deleted')
})
})
describe('loc.l8.deleteStoryOk: Delete the whole article', () => {
it('names the readable versions in the site order', async () => {
await open()
await choose('Delete article')
expect(dialogText()).toContain('Delete English, French, German and Arabic?')
expect(dialogText()).toContain(
"This deletes the article and every version you can read. It can't be undone.",
)
})
it('deletes the other versions one after another and the open one last', async () => {
const events: string[] = []
const slow = (id: number) => async () => {
events.push(`start ${id}`)
await new Promise((resolve) => setTimeout(resolve, 5))
events.push(`end ${id}`)
return null
}
await open(
{},
{
'DELETE /v1/article/10': slow(10),
'DELETE /v1/article/11': slow(11),
'DELETE /v1/article/12': slow(12),
'DELETE /v1/article/13': slow(13),
},
)
await choose('Delete article')
await press('Delete article', dialogBox()!)
await new Promise((resolve) => setTimeout(resolve, 60))
await settle()
expect(events).toEqual([
'start 10',
'end 10',
'start 11',
'end 11',
'start 13',
'end 13',
'start 12',
'end 12',
])
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
expect(toastText()).toContain('Article deleted')
})
it('never names or requests a version the owner cannot read', async () => {
await open({ translations: ['en', 'fr'] })
await choose('Delete article')
expect(dialogText()).toContain('Delete English and French?')
await press('Delete article', dialogBox()!)
expect(deletes()).toEqual(['/v1/article/10', '/v1/article/12'])
})
})
describe('loc.l8.deleteStory: The whole-article delete is refused', () => {
it('shows the blocked dialog without a toast and switches to the version confirmation', async () => {
await open({}, { 'DELETE /v1/article/10': failWith(403, 'missing_permission') })
await choose('Delete article')
await press('Delete article', dialogBox()!)
expect(dialogText()).toContain("You can't delete this article")
expect(dialogText()).toContain(
'Deleting an article needs delete permission on every version. You can still delete the version you are editing.',
)
expect(toastText()).not.toContain('permission')
expect(deletes()).toEqual(['/v1/article/10'])
const buttons = Array.from(dialogBox()!.querySelectorAll('button')).map((button) =>
button.textContent?.trim(),
)
expect(buttons.slice(-2)).toEqual(['Close', 'Delete French version'])
await press('Delete French version', dialogBox()!)
expect(dialogText()).toContain('Delete the French version?')
})
})
describe('A whole-article delete stops half way', () => {
it.each([
['a 500', failWith(500, 'Internal server error')],
['a 403', failWith(403, 'missing_permission')],
[
'a network failure',
() => {
throw new NetworkError()
},
],
])('requests nothing more after %s and refetches the open version', async (_name, failure) => {
await open({}, { 'DELETE /v1/article/11': failure })
const reads = callsTo('GET', '/v1/article/12').length
await choose('Delete article')
await press('Delete article', dialogBox()!)
expect(deletes()).toEqual(['/v1/article/10', '/v1/article/11'])
expect(toastText()).toContain("Couldn't delete the whole article")
expect(toastText()).toContain(
'Some versions were deleted. Open the language menu to see what is left.',
)
expect(callsTo('GET', '/v1/article/12').length).toBe(reads + 1)
expect(router.currentRoute.value.path).toBe('/article/12/edit')
})
it('skips a version that is already gone and carries on to the open one', async () => {
await open({}, { 'DELETE /v1/article/11': failWith(404, 'Article not found') })
await choose('Delete article')
await press('Delete article', dialogBox()!)
expect(deletes()).toEqual([
'/v1/article/10',
'/v1/article/11',
'/v1/article/13',
'/v1/article/12',
])
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
})
it('shows Version unavailable when the open version is gone after the failure', async () => {
let reads = 0
await open(
{},
{
'DELETE /v1/article/11': failWith(500, 'Internal server error'),
'GET /v1/article/12': () => {
reads += 1
if (reads > 1) {
throw new ApiError(404, undefined, 'Article not found')
}
return articleOf({ translations: [...ALL] })
},
},
)
await choose('Delete article')
await press('Delete article', dialogBox()!)
expect(text()).toContain("This version isn't available")
})
})
describe('A single-language site keeps the plain wording', () => {
it('reads Delete article and Delete this article? without naming a language', async () => {
await open({
lang: 'en',
translations: ['en'],
languages: [{ lang: 'en', versions: 1, published: 1 }],
})
await choose('Delete article')
expect(dialogText()).toContain('Delete this article?')
expect(dialogText()).not.toContain('English')
expect(text()).not.toContain('Delete English version')
})
})
@@ -243,9 +243,9 @@ describe('loc.l5.ovf: Collapse order by width', () => {
expect(menuItems().map((item) => item.textContent?.trim())).toEqual([
'Unpublish French',
`Edit slug${NOT_AVAILABLE}`,
`Move article${NOT_AVAILABLE}`,
'Move article',
'Delete French version',
`Delete article${NOT_AVAILABLE}`,
'Delete article',
])
})
})
@@ -259,22 +259,22 @@ describe('loc.perm.createNotAll: The overflow menu follows capabilities', () =>
expect(menuItems().map((item) => item.textContent?.trim())).toEqual([
`Edit slug${NOT_AVAILABLE}`,
`Move article${NOT_AVAILABLE}`,
'Move article',
'Delete French version',
`Delete article${NOT_AVAILABLE}`,
'Delete article',
])
})
it('shows Edit slug, Move article and Delete article disabled with a reason until they are wired', async () => {
it('keeps only Edit slug disabled with a reason; Move article and both deletes 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)
const slug = menuItem('Edit slug')!
expect(slug.getAttribute('aria-disabled')).toBe('true')
expect(slug.textContent).toContain(NOT_AVAILABLE)
for (const label of ['Move article', 'Delete French version', 'Delete article']) {
expect(menuItem(label)!.getAttribute('aria-disabled')).toBeNull()
}
expect(menuItem('Delete French version')!.getAttribute('aria-disabled')).toBeNull()
})
it('disables Delete article and Move with the reason when a sibling lacks delete', async () => {
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { initializeLocale } from '@/i18n'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { settle, text, unmountRoute } from './localizationKit'
@@ -159,12 +160,19 @@ describe('loc.l5b.withAdd, noAdd and roMenu: Add translation rows', () => {
expect(text()).not.toContain('Add translation')
})
it('renders the Add translation row without doing anything when chosen', async () => {
it('opens the Add a translation dialog for the row and closes the menu', async () => {
await mountEditor({ caps: FULL })
await openLanguageMenu()
rowFor('German')!.click()
await settle()
expect(router.currentRoute.value.fullPath).toBe(
'/article/12/edit?dialog=add-translation&lang=de',
)
expect(document.body.querySelector('[role="menu"]')).toBeNull()
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain(
'Add a translation',
)
expect(titleInput().value).toBe('Couts de renovation')
})
})
@@ -7,7 +7,6 @@ import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
import {
allMetaSections,
bylines,
callsTo,
click,
createMetaBackend,
failWith,
@@ -59,6 +58,20 @@ function listboxTriggers() {
)
}
function siteField() {
return document.body.querySelector<HTMLElement>(
'button[aria-haspopup="dialog"][aria-label="Site"]',
)!
}
function siteOptions() {
return Array.from(
document.body.querySelectorAll<HTMLElement>(
'[role="listbox"][aria-label="Site"] [role="option"]',
),
)
}
function bodyOpen() {
return document.body.querySelector('[data-testid="meta-body"]') !== null
}
@@ -283,56 +296,47 @@ describe('Editor section: the Site select', () => {
await mountMeta('editor', EDITOR, { backend })
const body = textOf(document.body.querySelector('[data-testid="meta-body"]'))
expect(listboxTriggers().map(textOf)[0]).toBe('Journal · journal.renew.example')
expect(textOf(siteField())).toBe('Journal · journal.renew.example')
expect(body).toContain('Canonical host: journal.renew.example')
expect(body.indexOf('Site')).toBeLessThan(body.indexOf('Authors'))
},
)
it('lists every site with its host, the default first', async () => {
it('opens the move panel from the Site field with the sites, the current one marked', async () => {
await mountMeta('editor', EDITOR, {
backend: backendWith('several', ['Inès Duarte'], 2),
})
listboxTriggers()[0]!.click()
siteField().click()
await flushPromises()
const options = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
).map(textOf)
expect(options).toEqual([
'Renew renew.example',
'Journal journal.renew.example',
'Projects projects.renew.example',
'Archive archive.renew.example',
expect(siteOptions().map((option) => textOf(option))).toEqual([
'Renew',
'Journal',
'Projects',
'Archive',
])
expect(
siteOptions()
.find((option) => textOf(option).startsWith('Journal'))!
.getAttribute('aria-selected'),
).toBe('true')
})
it('treats a change as an edit: nothing is sent until Save, then one PATCH with only site_id', async () => {
it('stages nothing when a site is chosen: Save stays disabled and no site_id is sent', async () => {
const backend = backendWith('several', ['Inès Duarte'], 1)
await mountMeta('editor', EDITOR, { backend })
expect(textOf(document.body)).toContain('Saved')
expect(saveButtonEl().disabled).toBe(true)
listboxTriggers()[0]!.click()
siteField().click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith('Journal'))!
siteOptions()
.find((option) => textOf(option).startsWith('Journal'))!
.click()
await flushPromises()
expect(textOf(document.body)).toContain('Unsaved changes')
expect(saveButtonEl().disabled).toBe(false)
expect(saveButtonEl().disabled).toBe(true)
expect(textOf(document.body)).not.toContain('Unsaved changes')
expect(patchCalls(backend)).toHaveLength(0)
saveButtonEl().click()
await flushPromises()
expect(patchCalls(backend)).toHaveLength(1)
expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 })
expect(textOf(document.body)).toContain('Saved')
expect(textOf(document.body.querySelector('[data-testid="meta-body"]'))).toContain(
'Canonical host: journal.renew.example',
)
})
it('still loads the editor when the sites cannot be loaded, with no select and no notice', async () => {
@@ -349,26 +353,6 @@ describe('Editor section: the Site select', () => {
})
describe('Editor section: a site or author that no longer exists', () => {
it('keeps the changes, shows Save failed, an info toast, and reloads the sites', async () => {
const backend = backendWith('several', ['Inès Duarte'], 1)
backend.responders['PATCH /v1/article/12'] = failWith(404, 'site_not_found')
await mountMeta('editor', EDITOR, { backend })
listboxTriggers()[0]!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith('Journal'))!
.click()
await flushPromises()
saveButtonEl().click()
await flushPromises()
expect(textOf(document.body)).toContain('Save failed')
expect(toasts()).toContain('That site no longer exists')
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(2)
expect(listboxTriggers()[0]!.textContent).toContain('Journal')
})
it('keeps the added author and shows the author toast on author_not_found', async () => {
const backend = backendWith('one', ['Inès Duarte'])
backend.responders['PATCH /v1/article/12'] = failWith(404, 'author_not_found')
@@ -396,7 +380,7 @@ describe('Editor section: the existing save behaviour treats the change like any
listboxTriggers()[0]!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith('Journal'))!
.find((item) => textOf(item).includes('Camille Roche'))!
.click()
await flushPromises()
}
@@ -410,10 +394,10 @@ describe('Editor section: the existing save behaviour treats the change like any
await flushPromises()
expect(patchCalls(backend)).toHaveLength(1)
expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 })
expect(patchCalls(backend)[0]!.body).toMatchObject({ author_ids: expect.any(Array) })
})
it('asks before leaving with an unsaved site change', async () => {
it('asks before leaving with an unsaved author change', async () => {
await changeSite(backendWith('several', ['Inès Duarte']))
const leaving = router.push('/article')
@@ -447,7 +431,7 @@ describe('Editor section: the existing save behaviour treats the change like any
}
})
it('keeps the chosen site in sessionStorage when the session expires and restores it', async () => {
it('keeps the added author in sessionStorage when the session expires and restores it', async () => {
const backend = backendWith('several', ['Inès Duarte'])
backend.responders['PATCH /v1/article/12'] = failWith(401)
await changeSite(backend)
@@ -455,7 +439,9 @@ describe('Editor section: the existing save behaviour treats the change like any
await flushPromises()
const stored = JSON.parse(sessionStorage.getItem('renewcms:article-draft:1:12')!)
expect(stored.meta).toMatchObject({ site_id: 2 })
expect(stored.meta.authors.map((author: { name: string }) => author.name)).toContain(
'Camille Roche',
)
expect(stored.body).toBeUndefined()
await unmountMeta()
@@ -463,6 +449,6 @@ describe('Editor section: the existing save behaviour treats the change like any
backend: backendWith('several', ['Inès Duarte']),
})
expect(textOf(document.body)).toContain('Unsaved changes')
expect(listboxTriggers()[0]!.textContent).toContain('Journal')
expect(textOf(metaSection())).toContain('Camille Roche')
})
})
+522
View File
@@ -0,0 +1,522 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { initializeLocale } from '@/i18n'
import { ApiError } from '@/lib/apiError'
import {
callsTo,
deferred,
dialogBox,
dialogText,
press,
settle,
text,
toastText,
unmountRoute,
} from './localizationKit'
import {
FULL,
READ,
REVISIONS,
articleOf,
buttonNamed,
editBody,
mountEditor,
openMoreActions,
type EditorFixture,
} from './editorKit'
import { languagesBody } from './localizationData'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const ALL = ['en', 'fr', 'de', 'ar'] as const
function site(id: number, key: string, name: string, isDefault = false) {
return {
id,
key,
name,
base_url: `https://${key}.example.com`,
default_social_image_id: null,
default_robots: 'index',
locale: 'en',
is_default: isDefault,
article_count: 4,
}
}
const SITES = [
site(1, 'journal', 'Journal', true),
site(2, 'atelier', 'Atelier'),
site(4, 'archive', 'Archive'),
site(3, 'notes', 'Notes'),
]
const MOVED = { story_id: 5, site: 'atelier', site_id: 2, article_ids: [10, 12, 11, 13] }
beforeEach(() => {
initializeLocale()
sessionStorage.clear()
})
afterEach(unmountRoute)
function open(fixture: EditorFixture = {}, extra: Record<string, unknown> = {}) {
return mountEditor({
translations: [...ALL],
siblingCaps: { ar: READ },
...fixture,
extra: {
'GET /v1/site/': SITES,
'GET /v1/site/notes/languages': languagesBody('n', 'en', [
{ lang: 'en', versions: 1, published: 1 },
]),
'POST /v1/story/5/move': MOVED,
...extra,
} as never,
})
}
function listbox() {
return document.body.querySelector<HTMLElement>('[role="listbox"][aria-label="Site"]')
}
function options() {
return Array.from(listbox()?.querySelectorAll<HTMLElement>('[role="option"]') ?? [])
}
function siteField() {
return document.body.querySelector<HTMLElement>(
'button[aria-haspopup="dialog"][aria-label="Site"]',
)!
}
async function openPanel() {
siteField().click()
await settle()
}
async function choosePanelSite(name: string) {
options()
.find((option) => option.textContent?.includes(name))!
.click()
await settle()
}
async function confirmMoveTo(name: string) {
await openPanel()
await choosePanelSite(name)
await press('Move article…')
}
function moveCalls() {
return callsTo('POST', '/v1/story/5/move')
}
describe('loc.l11b.moveSelect: The panel opens from the Site field or More actions', () => {
it('lists the sites in a listbox named Site with the note of readable versions', async () => {
await open()
await openPanel()
expect(options().map((option) => option.textContent?.trim())).toEqual([
'Journal',
'Atelier',
'Archive',
'Notes',
])
expect(options()[0]!.getAttribute('aria-selected')).toBe('true')
const note = dialogText()
expect(note).toContain('Moves all language versions of this article.')
for (const line of [
'English · Published',
'French · Draft',
'German · Draft',
'Arabic · Published · Read only',
]) {
expect(note).toContain(line)
}
})
it('opens from the More actions item too', async () => {
await open({ siblingCaps: { ar: FULL } })
await openMoreActions()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((item) => item.textContent?.trim().startsWith('Move article'))!
.click()
await settle()
expect(listbox()).not.toBeNull()
})
it('keeps Move article… disabled until another site is chosen and stages nothing', async () => {
await open()
await openPanel()
const primary = () =>
Array.from(dialogBox()!.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Move article…',
)!
expect(primary().hasAttribute('disabled')).toBe(true)
await choosePanelSite('Atelier')
expect(primary().hasAttribute('disabled')).toBe(false)
await choosePanelSite('Journal')
expect(primary().hasAttribute('disabled')).toBe(true)
expect(text()).not.toContain('Unsaved changes')
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(0)
})
it('lists no sentence or footnote about versions the caller cannot read', async () => {
await open({ translations: ['en', 'fr', 'de'] })
await openPanel()
expect(dialogText()).toContain('English · Published')
expect(dialogText()).not.toContain('Arabic')
expect(dialogText()).not.toMatch(/can't read|hidden|unreadable/i)
})
it('moves focus into the listbox, arrows move and the panel closes on Escape', async () => {
await open()
await openPanel()
expect(document.activeElement).toBe(options()[0])
document.activeElement!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
)
await settle()
expect(document.activeElement).toBe(options()[1])
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await settle()
expect(listbox()).toBeNull()
})
})
describe('loc.l11b.moveConfirm, moveBusy and moveDone', () => {
it('confirms with the versions and the addresses alert', async () => {
await open()
await openPanel()
await choosePanelSite('Atelier')
await press('Move article…')
expect(dialogText()).toContain('Move to Atelier?')
expect(dialogText()).toContain('This moves every version together. Nothing is split.')
expect(dialogText()).toContain('English · Published')
expect(dialogText()).toContain('Addresses and links')
expect(dialogText()).toContain(
'Each version keeps its address. Links from the old site to it will stop working. RenewCMS does not create redirects.',
)
})
it('sends the site key and locks the dialog while moving', async () => {
const gate = deferred<unknown>()
await open({}, { 'POST /v1/story/5/move': () => gate.promise })
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(moveCalls()[0]!.body).toEqual({ site: 'atelier' })
expect(dialogText()).toContain('Moving to Atelier…')
expect(dialogText()).toContain('All 4 versions move together. Keep this page open.')
expect(dialogText()).toContain('This takes a few seconds.')
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await settle()
expect(dialogBox()).not.toBeNull()
gate.resolve(MOVED)
await settle()
})
it('toasts, announces, refetches silently, keeps unsaved edits and saves with the new revision', async () => {
let reads = 0
await open(
{},
{
'GET /v1/article/12': () => {
reads += 1
return reads === 1
? articleOf({ translations: [...ALL] })
: { ...articleOf({ translations: [...ALL] }), site_id: 2, revision: 8 }
},
'PATCH /v1/article/12': { ...articleOf({}), revision: 9 },
},
)
await editBody('<p>Edits</p>')
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(dialogBox()).toBeNull()
expect(toastText()).toContain('Moved to Atelier')
expect(toastText()).toContain('All 4 versions moved together.')
expect(document.body.querySelector('[data-testid="live-announcement"]')?.textContent).toBe(
'Moved to Atelier',
)
expect(reads).toBe(2)
expect(text()).toContain('Unsaved changes')
await press('Save')
expect(callsTo('PATCH', '/v1/article/12')[0]!.body).toEqual({
body: '<p>Edits</p>',
if_revision: 8,
})
expect(REVISIONS.fr).toBe(3)
})
})
describe('The silent refetch after a move', () => {
const OUT_OF_DATE = 'This page may be out of date. Reload it to see the latest.'
function announcement() {
return document.body.querySelector('[data-testid="live-announcement"]')?.textContent
}
it('retries once and keeps quiet when the second read succeeds', async () => {
let reads = 0
await open(
{},
{
'GET /v1/article/12': () => {
reads += 1
if (reads === 2) {
throw new ApiError(500, undefined, 'Internal server error')
}
return articleOf({ translations: [...ALL] })
},
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
await settle()
expect(reads).toBe(3)
expect(announcement()).toBe('Moved to Atelier')
})
it('announces politely that the page may be out of date after two failures and keeps edits', async () => {
let reads = 0
await open(
{},
{
'GET /v1/article/12': () => {
reads += 1
if (reads > 1) {
throw new ApiError(500, undefined, 'Internal server error')
}
return articleOf({ translations: [...ALL] })
},
},
)
await editBody('<p>Edits</p>')
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
await settle()
expect(reads).toBe(3)
expect(announcement()).toBe(OUT_OF_DATE)
expect(text()).toContain('Unsaved changes')
})
it('keeps what changed while the refetch was in flight', async () => {
const pending = deferred<ReturnType<typeof articleOf>>()
let reads = 0
await open(
{ translations: [...ALL] },
{
'GET /v1/article/12': () => {
reads += 1
return reads === 1 ? articleOf({ translations: [...ALL] }) : pending.promise
},
'PATCH /v1/article/12/publish': articleOf({
translations: [...ALL],
online: { fr: true },
}),
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
await press('Publish French')
await press('Publish French', dialogBox()!)
pending.resolve(articleOf({ translations: [...ALL] }))
await settle()
expect(buttonNamed('Unpublish French')).toBeTruthy()
})
})
describe('Refused moves keep the dialog open and move nothing', () => {
function primaryDisabled() {
return Array.from(dialogBox()!.querySelectorAll('button'))
.find((button) => button.textContent?.trim() === 'Move article')!
.hasAttribute('disabled')
}
it('loc.l11b.move: names the missing readable languages from the destination', async () => {
await open(
{},
{
'POST /v1/story/5/move': () => {
throw new ApiError(422, undefined, 'language_not_enabled', {
missingLanguages: ['fr', 'de', 'ar'],
})
},
},
)
await confirmMoveTo('Notes')
await press('Move article', dialogBox()!)
expect(callsTo('GET', '/v1/site/notes/languages')).toHaveLength(1)
expect(dialogText()).toContain("Notes doesn't enable every language in this article")
expect(dialogText()).toContain(
'Notes enables English only. Enable French, German and Arabic on Notes first. Languages disabled on Journal count too. Nothing was moved.',
)
expect(primaryDisabled()).toBe(true)
})
it('never names a language the owner cannot read', async () => {
await open(
{ translations: ['en', 'fr', 'de'] },
{
'POST /v1/story/5/move': () => {
throw new ApiError(422, undefined, 'language_not_enabled', {
missingLanguages: ['fr', 'de', 'ar'],
})
},
},
)
await confirmMoveTo('Notes')
await press('Move article', dialogBox()!)
expect(dialogText()).toContain('Enable French and German on Notes first.')
expect(dialogText()).not.toContain('Arabic')
})
it('loc.l11b.moveSlug: names the readable conflicting version with its address and no Edit slug', async () => {
await open(
{},
{
'POST /v1/story/5/move': () => {
throw new ApiError(409, undefined, 'slug_conflict', {
conflictingArticles: [12],
})
},
'GET /v1/article/12': { ...articleOf({ translations: [...ALL] }), slug: 'couts' },
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(dialogText()).toContain('An address is already used on Atelier')
expect(dialogText()).toContain(
'The French address "couts" is already used by another French article on Atelier. Change it before moving. Nothing was moved.',
)
expect(dialogText()).not.toContain('Edit slug')
expect(primaryDisabled()).toBe(true)
})
it('writes one sentence per conflicting readable version and skips hidden ones', async () => {
await open(
{},
{
'POST /v1/story/5/move': () => {
throw new ApiError(409, undefined, 'slug_conflict', {
conflictingArticles: [12, 11, 99],
})
},
'GET /v1/article/12': { ...articleOf({ translations: [...ALL] }), slug: 'couts' },
'GET /v1/article/11': {
...articleOf({ translations: [...ALL] }, 'de'),
slug: 'kosten',
},
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(dialogText()).toContain('The French address "couts"')
expect(dialogText()).toContain('The German address "kosten"')
expect(callsTo('GET', '/v1/article/99')).toHaveLength(0)
})
it('loc.l11b.movePerm: a neutral dialog with Close and no global toast on a 403', async () => {
await open(
{},
{
'POST /v1/story/5/move': () => {
throw new ApiError(403, undefined, 'missing_permission', {
code: 'missing_permission',
})
},
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(dialogText()).toContain("You can't move this article")
expect(dialogText()).toContain('Moving needs edit permission on every version')
expect(dialogText()).toContain('Ask an owner to move it. Nothing was changed.')
expect(toastText()).not.toContain('permission')
await press('Close', dialogBox()!)
expect(dialogBox()).toBeNull()
})
it.each([
['a 500', () => new ApiError(500)],
['a network failure', () => new TypeError('Failed to fetch')],
])("shows Couldn't move the article after %s", async (_name, make) => {
await open(
{},
{
'POST /v1/story/5/move': () => {
throw make()
},
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(dialogText()).toContain("Couldn't move the article")
expect(dialogText()).toContain('Nothing was moved. Try again.')
})
it('refetches the sites after a 404 site_not_found', async () => {
await open(
{},
{
'POST /v1/story/5/move': () => {
throw new ApiError(404, undefined, 'site_not_found')
},
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(dialogText()).toContain("Couldn't move the article")
expect(callsTo('GET', '/v1/site/')).toHaveLength(2)
})
it('closes the dialog and shows Version unavailable after a 404 Article not found', async () => {
await open(
{},
{
'POST /v1/story/5/move': () => {
throw new ApiError(404, undefined, 'Article not found')
},
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(dialogBox()).toBeNull()
expect(text()).toContain("This version isn't available")
})
})
@@ -0,0 +1,543 @@
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,
dialogBox,
dialogText,
failWith,
kit,
press,
settle,
text,
toastText,
typeInto,
unmountRoute,
} from './localizationKit'
import {
REVISIONS,
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 KEY = 'renewcms:article-draft:7:12'
const THEIRS_AT = '2026-10-14T14:32:00Z'
const AGAIN_AT = '2026-10-14T14:41:00Z'
const THREE = ['en', 'fr', 'de'] as const
beforeEach(() => {
initializeLocale()
sessionStorage.clear()
})
afterEach(async () => {
vi.unstubAllGlobals()
await unmountRoute()
})
function patchBodies() {
return callsTo('PATCH', '/v1/article/12').map((call) => call.body)
}
function readBlob(blob: Blob) {
return new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.readAsText(blob)
})
}
const conflict = (revision = 4) => failWith(409, 'revision_conflict', { currentRevision: revision })
function theirs(updatedAt = THEIRS_AT, revision = 4) {
return { ...articleOf({}), title: 'Couts par un autre', revision, updated_at: updatedAt }
}
function conflictRoutes(overrides: Record<string, unknown> = {}) {
let reads = 0
return {
'PATCH /v1/article/12': conflict(),
'GET /v1/article/12': () => {
reads += 1
return reads === 1 ? articleOf({}) : theirs()
},
...overrides,
} as never
}
async function openConflict(overrides: Record<string, unknown> = {}) {
await mountEditor({ extra: conflictRoutes(overrides) })
await editBody('<p>Mes changements</p>')
await press('Save')
}
describe('Every save of the open version carries if_revision', () => {
it('sends the loaded revision with the changed fields and takes the revision of the answer', async () => {
await mountEditor({
extra: { 'PATCH /v1/article/12': { ...articleOf({}), revision: 4 } },
})
await editBody('<p>One</p>')
await press('Save')
await editBody('<p>Two</p>')
await press('Save')
expect(patchBodies()).toEqual([
{ body: '<p>One</p>', if_revision: REVISIONS.fr },
{ body: '<p>Two</p>', if_revision: 4 },
])
})
it('sends it when saving before publishing', async () => {
await mountEditor({
translations: [...THREE],
extra: {
'PATCH /v1/article/12': articleOf({ translations: [...THREE] }),
'PATCH /v1/article/12/publish': articleOf({
translations: [...THREE],
online: { fr: true },
}),
} as never,
})
await editBody('<p>One</p>')
await press('Save and publish French')
await press('Save and publish French', dialogBox()!)
expect(patchBodies()[0]).toMatchObject({ if_revision: 3 })
})
it('sends it when saving before opening English', async () => {
await mountEditor({
translations: [...THREE],
extra: {
'PATCH /v1/article/12': articleOf({ translations: [...THREE] }),
'GET /v1/article/10': articleOf({ lang: 'fr', translations: [...THREE] }, 'en'),
} as never,
})
await editBody('<p>One</p>')
await openLanguageMenu()
rowFor('English')!.click()
await settle()
await press('Save and open English')
expect(patchBodies()[0]).toMatchObject({ if_revision: 3 })
})
it('sends the same revision again on Try saving again', async () => {
let failures = 1
await mountEditor({
extra: {
'PATCH /v1/article/12': () => {
if (failures-- > 0) {
throw new NetworkError()
}
return articleOf({})
},
} as never,
})
await editBody('<p>One</p>')
await press('Save')
await press('Try saving again')
expect(patchBodies()).toEqual([
{ body: '<p>One</p>', if_revision: 3 },
{ body: '<p>One</p>', if_revision: 3 },
])
})
it('sends it on the title helper Try again', async () => {
await mountEditor({ extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
await typeInto(titleInput(), 'Couts reels')
await press('Save')
await press('Try again')
expect(
patchBodies().every((body) => (body as { if_revision: number }).if_revision === 3),
).toBe(true)
})
it('publishes and unpublishes with no body and leaves the base revision', async () => {
await mountEditor({
translations: [...THREE],
extra: {
'PATCH /v1/article/12/publish': articleOf({
translations: [...THREE],
online: { fr: true },
}),
'PATCH /v1/article/12': { ...articleOf({}), revision: 9 },
} as never,
})
await press('Publish French')
await press('Publish French', dialogBox()!)
await editBody('<p>One</p>')
await press('Save')
expect(callsTo('PATCH', '/v1/article/12/publish')[0]!.body).toBeUndefined()
expect(patchBodies()[0]).toMatchObject({ if_revision: 3 })
})
})
describe('loc.l11.conflictRestored: A restored draft keeps the revision it was written against', () => {
it('sends revision 3 while the server is at 5 and shows the Restored notice in the dialog', async () => {
sessionStorage.setItem(
KEY,
JSON.stringify({
body: '<p>Stored body</p>',
revision: 3,
mirrored_at: '2026-10-14T14:18:00Z',
}),
)
await mountEditor({
extra: conflictRoutes({
'GET /v1/article/12': { ...articleOf({}), revision: 5 },
}),
})
await press('Save')
expect(patchBodies()[0]).toMatchObject({ if_revision: 3 })
expect(dialogText()).toContain('Restored unsaved changes from 14:18')
expect(dialogText()).toContain(
'Your session ended. These changes were kept in this browser.',
)
})
it('uses the revision loaded now for a stored draft that has none', async () => {
sessionStorage.setItem(
KEY,
JSON.stringify({ body: '<p>Stored body</p>', mirrored_at: '2026-10-14T14:18:00Z' }),
)
await mountEditor({ extra: { 'PATCH /v1/article/12': articleOf({}) } })
await press('Save')
expect(patchBodies()[0]).toMatchObject({ if_revision: 3 })
})
})
describe('loc.conflict: A stale save opens the dialog', () => {
it('reads their version for the time and offers the four choices in order', async () => {
await openConflict()
expect(callsTo('GET', '/v1/article/12')).toHaveLength(2)
expect(dialogText()).toContain('Someone else saved this version')
expect(dialogText()).toContain(
"Someone else saved this version at 14:32. Your changes aren't saved. They stay here until you choose.",
)
const labels = Array.from(dialogBox()!.querySelectorAll('button'))
.map((button) => button.textContent?.trim())
.filter((label) => label && label !== 'Close')
expect(labels).toEqual([
'Keep my changes',
'Download my version',
'Reload theirs',
'Save over theirs',
])
expect(dialogText()).not.toMatch(/revision|\b4\b/i)
expect(text()).toContain('Unsaved changes')
})
it('starts focus on Keep my changes', async () => {
await openConflict()
expect(document.activeElement?.textContent?.trim()).toBe('Keep my changes')
})
it('fills only Keep my changes', async () => {
await openConflict()
const keep = Array.from(dialogBox()!.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Keep my changes',
)!
const others = ['Download my version', 'Reload theirs', 'Save over theirs'].map(
(label) =>
Array.from(dialogBox()!.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === label,
)!,
)
expect(others.every((button) => button.className === others[0]!.className)).toBe(true)
expect(keep.className).not.toBe(others[0]!.className)
})
it('shows all four options on a phone', async () => {
await mountEditor({ width: 390, extra: conflictRoutes() })
await editBody('<p>Mes changements</p>')
await press('Save')
expect(dialogText()).toContain('Save over theirs')
expect(dialogBox()!.className).toMatch(/max-sm:/)
})
})
describe('Conflicts during a chained action stop the chain', () => {
it('never sends publish after a conflict on Save and publish', async () => {
await mountEditor({
translations: [...THREE],
extra: {
'PATCH /v1/article/12': conflict(),
'GET /v1/article/12': () => articleOf({ translations: [...THREE] }),
} as never,
})
await editBody('<p>One</p>')
await press('Save and publish French')
await press('Save and publish French', dialogBox()!)
expect(dialogText()).toContain('Someone else saved this version')
expect(patchBodies()).toHaveLength(1)
expect(callsTo('PATCH', '/v1/article/12/publish')).toHaveLength(0)
})
it('never requests English after a conflict on Save and open English', async () => {
await mountEditor({
translations: [...THREE],
extra: {
'PATCH /v1/article/12': conflict(),
'GET /v1/article/12': () => articleOf({ translations: [...THREE] }),
'GET /v1/article/10': articleOf({ lang: 'fr' }, 'en'),
} as never,
})
await editBody('<p>One</p>')
await openLanguageMenu()
rowFor('English')!.click()
await settle()
await press('Save and open English')
expect(dialogText()).toContain('Someone else saved this version')
expect(patchBodies()).toHaveLength(1)
expect(callsTo('GET', '/v1/article/10')).toHaveLength(0)
})
})
describe('The conflict reload fails', () => {
it.each([
['a 500', failWith(500, 'Internal server error')],
[
'a network failure',
() => {
throw new NetworkError()
},
],
])('shows Save failed with the edits kept after %s', async (_name, failure) => {
let reads = 0
await mountEditor({
extra: {
'PATCH /v1/article/12': conflict(),
'GET /v1/article/12': (call: unknown) => {
reads += 1
return reads === 1 ? articleOf({}) : (failure as (c: unknown) => unknown)(call)
},
} as never,
})
await editBody('<p>Mes changements</p>')
await press('Save')
expect(dialogBox()).toBeNull()
expect(text()).toContain('Save failed')
await press('Try saving again')
expect(patchBodies()).toHaveLength(2)
expect(patchBodies()[1]).toEqual(patchBodies()[0])
})
})
describe('Keep my changes', () => {
it('closes the dialog, sends nothing and keeps the edits; the next Save conflicts again', async () => {
await openConflict()
await press('Keep my changes')
expect(dialogBox()).toBeNull()
expect(patchBodies()).toHaveLength(1)
expect(text()).toContain('Unsaved changes')
await press('Save')
expect(patchBodies()).toHaveLength(2)
expect(dialogText()).toContain('Someone else saved this version')
})
it('treats Escape like Keep my changes and returns focus to the title', async () => {
await openConflict()
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await settle()
expect(dialogBox()).toBeNull()
expect(document.activeElement).toBe(titleInput())
})
})
describe('loc.l11.download: Download my version', () => {
it('downloads the title, a blank line and the body, with a toast and no request', async () => {
const blobs: Blob[] = []
URL.createObjectURL = vi.fn((blob: Blob | MediaSource) => {
blobs.push(blob as Blob)
return 'blob:conflict'
})
URL.revokeObjectURL = vi.fn()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
await openConflict()
const requests = kit.calls.length
await press('Download my version')
expect(await readBlob(blobs[0]!)).toBe('Couts de renovation\n\nMes changements\n')
expect(kit.calls).toHaveLength(requests)
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
expect(toastText()).toContain('Download started')
expect(toastText()).toContain(
'Your French title and body are saved as a text file. Your changes are still in the editor.',
)
expect(document.body.querySelector('[data-testid="live-announcement"]')?.textContent).toBe(
'Download started',
)
expect(dialogBox()).not.toBeNull()
})
})
describe('loc.l11.reloadConfirm: Reload theirs asks first', () => {
it('switches to the confirmation and discards the edits when confirmed', async () => {
await openConflict()
sessionStorage.setItem(KEY, JSON.stringify({ body: '<p>x</p>', mirrored_at: THEIRS_AT }))
await press('Reload theirs')
expect(dialogText()).toContain('Reload theirs?')
expect(dialogText()).toContain('This discards your unsaved changes.')
await press('Reload and discard my changes')
expect(dialogBox()).toBeNull()
expect(titleInput().value).toBe('Couts par un autre')
expect(text()).not.toContain('Unsaved changes')
expect(sessionStorage.getItem(KEY)).toBeNull()
await editBody('<p>Next</p>')
await press('Save')
expect(patchBodies().at(-1)).toMatchObject({ if_revision: 4 })
})
it('goes back to editing with Keep my changes from the confirmation', async () => {
await openConflict()
await press('Reload theirs')
await press('Keep my changes')
expect(dialogBox()).toBeNull()
expect(text()).toContain('Unsaved changes')
})
})
describe('loc.l11.conflict2 and Save over theirs outcomes', () => {
function routesWith(second: unknown) {
let patches = 0
let reads = 0
return {
'PATCH /v1/article/12': (call: unknown) => {
patches += 1
if (patches === 1) {
return conflict()(call as never)
}
return typeof second === 'function'
? (second as (c: unknown) => unknown)(call)
: second
},
'GET /v1/article/12': () => {
reads += 1
if (reads === 1) {
return articleOf({})
}
return reads === 2 ? theirs() : theirs(AGAIN_AT, 6)
},
} as never
}
it('sends if_revision 4, closes the dialog and takes the new revision', async () => {
await mountEditor({ extra: routesWith({ ...articleOf({}), revision: 5 }) })
await editBody('<p>Mes changements</p>')
await press('Save')
const reads = callsTo('GET', '/v1/article/12').length
await press('Save over theirs')
expect(patchBodies()[1]).toMatchObject({ body: '<p>Mes changements</p>', if_revision: 4 })
expect(callsTo('GET', '/v1/article/12')).toHaveLength(reads)
expect(dialogBox()).toBeNull()
expect(text()).toContain('Saved')
expect(callsTo('GET', '/v1/article/10')).toHaveLength(0)
})
it('reads again on a second conflict, keeps the dialog and shows the newer time', async () => {
await mountEditor({
extra: routesWith(failWith(409, 'revision_conflict', { currentRevision: 6 })),
})
await editBody('<p>Mes changements</p>')
await press('Save')
await press('Save over theirs')
expect(dialogText()).toContain("Couldn't replace theirs")
expect(dialogText()).toContain(
'Someone else saved this version again at 14:41, before your save went through. Nothing was overwritten. Your changes are still here.',
)
await press('Save over theirs')
expect(patchBodies()[2]).toMatchObject({ if_revision: 6 })
})
it.each([
['a 500', failWith(500, 'Internal server error')],
[
'a network failure',
() => {
throw new NetworkError()
},
],
])('keeps the dialog with a retryable alert after %s', async (_name, failure) => {
await mountEditor({ extra: routesWith(failure) })
await editBody('<p>Mes changements</p>')
await press('Save')
await press('Save over theirs')
expect(dialogText()).toContain("Couldn't replace theirs")
expect(dialogText()).toContain(
'Nothing was overwritten. Your changes are still here. Try again.',
)
expect(buttonNamed('Save over theirs')!.hasAttribute('disabled')).toBe(false)
})
it('mirrors the draft and goes to login on a 401', async () => {
await mountEditor({ extra: routesWith(failWith(401)) })
await editBody('<p>Mes changements</p>')
await press('Save')
await press('Save over theirs')
expect(JSON.parse(sessionStorage.getItem(KEY)!)).toMatchObject({
body: '<p>Mes changements</p>',
})
})
it('closes the dialog on a 403 and shows Save failed', async () => {
await mountEditor({ extra: routesWith(failWith(403, 'missing_permission')) })
await editBody('<p>Mes changements</p>')
await press('Save')
await press('Save over theirs')
expect(dialogBox()).toBeNull()
expect(text()).toContain('Save failed')
})
it('closes the dialog and shows Version unavailable on a 404', async () => {
await mountEditor({ extra: routesWith(failWith(404, 'Article not found')) })
await editBody('<p>Mes changements</p>')
await press('Save')
await press('Save over theirs')
expect(dialogBox()).toBeNull()
expect(text()).toContain("This version isn't available")
expect(router.currentRoute.value.path).toBe('/article/12/edit')
})
})
+5 -1
View File
@@ -174,7 +174,11 @@ describe('loc.l5.titleSaving: Saving the title sends it with the other edits', (
const calls = callsTo('PATCH', '/v1/article/12')
expect(calls).toHaveLength(1)
expect(calls[0]!.body).toEqual({ title: 'Couts reels', body: '<p>New body</p>' })
expect(calls[0]!.body).toEqual({
title: 'Couts reels',
body: '<p>New body</p>',
if_revision: 3,
})
})
it('shows a spinner and "Saving title…" while in flight', async () => {
+1 -1
View File
@@ -247,7 +247,7 @@ describe('A successful create hands off to the editor with a toast', () => {
await flushPromises()
const confirmButton = Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Create draft',
(button) => button.textContent?.trim() === 'Create article',
)
confirmButton?.click()
await flushPromises()
+2
View File
@@ -20,6 +20,7 @@ export const TITLES: Record<Lang, string> = {
de: 'Renovierungskosten',
}
const ONLINE: Record<Lang, boolean> = { en: true, fr: false, ar: true, de: false }
export const REVISIONS: Record<Lang, number> = { en: 7, fr: 3, ar: 5, de: 2 }
const UPDATED_AT = '2026-09-26T11:02:00Z'
const TRANSLATION_ORDER: Lang[] = ['en', 'ar', 'fr']
@@ -87,6 +88,7 @@ export function articleOf(fixture: EditorFixture, lang: Lang = fixture.lang ?? '
is_online: fixture.online?.[lang] ?? ONLINE[lang],
lang,
story_id: 5,
revision: REVISIONS[lang],
capabilities: capsOf(fixture, lang),
translations: order.map((tag) => entryOf(fixture, tag)),
site_id: 1,
+3 -2
View File
@@ -7,6 +7,7 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { ApiError, useApi } from '@/composables/useApi'
import type { ApiErrorDetails } from '@/lib/apiError'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { accessFor } from '@/test/access'
@@ -22,9 +23,9 @@ export type Route = unknown | ((call: Call) => unknown | Promise<unknown>)
export const kit: { wrapper: VueWrapper | null; calls: Call[] } = { wrapper: null, calls: [] }
export function failWith(status: number, body?: string): Route {
export function failWith(status: number, body?: string, details?: ApiErrorDetails): Route {
return () => {
throw new ApiError(status, undefined, body)
throw new ApiError(status, undefined, body, details)
}
}