feat: site languages, setup and site URL settings

This commit is contained in:
Florian Sylvain committed 2026-11-01 22:13:02 +01:00
1 parent 0806733d5c
commit d9158025d4
106 files changed
+10184 -56

No files matched your search

+380
View File
@@ -0,0 +1,380 @@
<script setup lang="ts">
import { t, locale } from '@/i18n'
import { computed, onMounted, toRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import DashboardShell from '@/components/DashboardShell.vue'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleButton from '@/components/localization/LocaleButton.vue'
import LocaleInput from '@/components/localization/LocaleInput.vue'
import LocaleSelect from '@/components/localization/LocaleSelect.vue'
import RetryAlert from '@/components/RetryAlert.vue'
import { useLanguageSetupPage } from '@/composables/useLanguageSetupPage'
import type { SetupSite } from '@/composables/useSiteLanguages'
import { baseLanguage, languageName } from '@/lib/siteLanguages'
import { useSessionStore } from '@/stores/session'
const route = useRoute()
const session = useSessionStore()
const siteKey = computed(() => String(route.params.key ?? ''))
const page = useLanguageSetupPage(toRef(siteKey))
const { loadState, sites, stage } = page
const sessionReady = computed(() => session.status === 'ready')
const known = computed(() => page.knownSiteName.value !== null)
const siteName = computed(() => page.knownSiteName.value ?? siteKey.value)
const languagesPath = computed(() =>
known.value
? `/settings/sites/${encodeURIComponent(siteKey.value)}/languages`
: '/settings/sites',
)
const items = computed(() =>
(page.setup.value?.available ?? [])
.filter((option) => option.substitute === null)
.map((option) => ({
value: option.lang,
label: optionLabel(option.lang),
})),
)
const unseededNames = computed(() =>
sites.value.filter((site) => site.suggestion === null).map((site) => site.name),
)
const confirmDisabled = computed(
() => !page.everyRowChosen.value || stage.value === 'applying' || stage.value === 'inProgress',
)
const showRequiredError = computed(() => page.onlyUnseeded.value && stage.value === 'editing')
onMounted(page.load)
watch(siteKey, page.load)
const breadcrumb = computed(() => [
{ label: t('Settings'), to: '/settings' },
{ label: t('Sites'), to: '/settings/sites' },
...(known.value
? [
{ label: siteName.value },
{ label: t('Languages'), to: languagesPath.value },
{ label: t('Setup') },
]
: [{ label: t('Setup') }]),
])
function optionLabel(tag: string) {
return `${languageName(tag, locale.value)} · ${tag}`
}
function articles(count: number) {
return count === 1 ? t('1 article') : t('{0} articles', [count])
}
function localeNote(site: SetupSite) {
return site.locale ? t('Site locale {0}', [site.locale]) : t('No site locale')
}
function help(site: SetupSite) {
const chosen = page.choices.value[site.key]
if (stage.value === 'done') {
return undefined
}
if (site.suggestion?.substituted && site.locale) {
return t(
'Only the broader language {0} ({1}) is supported. Pages and the API will say {1}, not {2}.',
[
languageName(baseLanguage(site.suggestion.lang), locale.value),
site.suggestion.lang,
site.locale,
],
)
}
if (site.suggestion && site.locale) {
return t('Seeded from site locale {0}. Confirm or change.', [site.locale])
}
return chosen ? undefined : t('Required before you can apply.')
}
function emptySiteHelp(site: SetupSite) {
return site.suggestion && site.locale && !site.suggestion.substituted
? t('Seeded from site locale {0}. The full locale is kept. Confirm or change it.', [
site.locale,
])
: help(site)
}
function fieldError(site: SetupSite) {
return showRequiredError.value && !page.choices.value[site.key]
? t('Required before you can apply.')
: undefined
}
function shown(site: SetupSite) {
const chosen = page.choices.value[site.key]
return chosen ? optionLabel(chosen) : ''
}
</script>
<template>
<DashboardShell
compact-padding
:mobile-title="t('Set article languages')"
:back-to="languagesPath"
:back-label="known ? t('Back to Languages') : t('Back to Sites')"
no-current-nav
>
<template #breadcrumb>
<UBreadcrumb
:items="breadcrumb"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<div v-if="sessionReady" class="flex flex-col gap-4">
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">
{{ page.singleEmptySite.value ? t('Languages') : t('Set article languages') }}
</h1>
<p class="text-pretty text-toned">
{{
page.singleEmptySite.value
? t('Content languages for {0}.', [sites[0]!.name])
: t(
"Existing articles don't have a language yet. Confirm or change the language for each site.",
)
}}
</p>
</div>
<div
v-if="loadState === 'loading'"
class="h-44 bg-elevated"
role="status"
:aria-label="t('Loading')"
/>
<RetryAlert
v-else-if="loadState === 'error'"
:title="t('Couldn\'t load languages')"
:description="t('Check your connection and try again.')"
@retry="page.load"
/>
<template v-else-if="loadState === 'ready'">
<LocaleAlert
v-if="stage === 'done'"
tone="success"
icon="i-lucide-circle-check"
:title="t('Languages assigned')"
:description="
page.assigned.value === null
? t('Nothing was translated, and no articles were grouped.')
: page.assigned.value === 1
? t(
'1 article now has a language. Nothing was translated, and no articles were grouped.',
)
: t(
'{0} articles now have a language. Nothing was translated, and no articles were grouped.',
[page.assigned.value],
)
"
>
<template #actions>
<LocaleButton small to="/article">{{ t('Open Articles') }}</LocaleButton>
</template>
</LocaleAlert>
<LocaleAlert
v-else-if="stage === 'failed'"
tone="error"
icon="i-lucide-circle-x"
:title="t('Nothing was changed')"
:description="
t(
'The update failed, so no article has a language yet. Your choices are kept. Try again. Running it twice is safe.',
)
"
>
<template #actions>
<LocaleButton small @click="page.apply">{{ t('Try again') }}</LocaleButton>
</template>
</LocaleAlert>
<LocaleAlert
v-else-if="stage === 'inProgress'"
tone="info"
icon="i-lucide-loader-circle"
:title="t('Setup is already running')"
:description="
t(
'Another owner started it. Languages appear here when it finishes. Articles created meanwhile get each site\'s default language. Starting it again is safe: sites that already have a language are skipped.',
)
"
>
<template #actions>
<LocaleButton small :loading="page.checking.value" @click="page.checkAgain">
{{ t('Check again') }}
</LocaleButton>
</template>
</LocaleAlert>
<LocaleAlert
v-else-if="stage === 'applying'"
tone="info"
icon="i-lucide-loader-circle"
:title="t('Assigning languages')"
:description="
t('Articles created while this runs get the site\'s default language.')
"
/>
<LocaleAlert
v-else-if="showRequiredError"
tone="error"
icon="i-lucide-circle-x"
:title="t('Choose a language for {0}', [unseededNames.join(', ')])"
:description="
unseededNames.length === 1
? t(
'{0} has no site locale, so there is nothing to start from. Choose the language its articles are written in.',
[unseededNames[0]],
)
: t(
'{0} have no site locale, so there is nothing to start from. Choose the language their articles are written in.',
[unseededNames.join(', ')],
)
"
/>
<LocaleAlert
v-else-if="sites.length > 0 && !page.singleEmptySite.value"
tone="info"
icon="i-lucide-info"
:title="t('This labels articles. It doesn\'t translate them.')"
:description="
t(
'Each existing article becomes the only version of its own article group. Each site\'s language starts from its locale with the region kept: pt-BR stays Portuguese (Brazil). Nothing is applied until you confirm.',
)
"
/>
<div
v-if="page.singleEmptySite.value && stage !== 'done'"
class="flex flex-col gap-3 border border-default bg-default p-4"
>
<span class="font-semibold text-highlighted">{{
t('Confirm the default language')
}}</span>
<span class="text-toned">{{
t(
'{0} has no articles yet. New articles will be written in this language.',
[sites[0]!.name],
)
}}</span>
<UFormField
:label="t('Default content language')"
:help="emptySiteHelp(sites[0]!)"
:ui="{
label: 'font-medium text-highlighted',
container: 'mt-1.5',
help: 'mt-1.5 text-xs/4.5 text-pretty text-muted',
}"
>
<LocaleSelect
:model-value="page.choices.value[sites[0]!.key]"
:items="items"
:placeholder="t('Choose a language')"
:aria-label="t('Default content language')"
@update:model-value="(lang) => page.choose(sites[0]!.key, lang)"
/>
</UFormField>
<div class="flex">
<LocaleButton
kind="primary"
:disabled="confirmDisabled"
:loading="stage === 'applying'"
@click="page.apply"
>
{{ stage === 'applying' ? t('Assigning…') : t('Save') }}
</LocaleButton>
</div>
</div>
<template v-else-if="sites.length > 0">
<div
class="border border-default bg-default"
role="group"
:aria-label="t('Sites')"
>
<div
v-for="site in sites"
:key="site.key"
class="flex min-h-12 flex-wrap items-start gap-3 border-t border-default px-4 py-2.5 max-md:min-h-14 md:items-center"
:data-testid="`setup-row-${site.key}`"
>
<div class="flex min-w-35 flex-1 flex-col">
<span class="font-semibold text-highlighted">{{ site.name }}</span>
<span class="text-xs/5 text-muted">
{{ articles(site.article_count) }} · {{ localeNote(site) }}
</span>
</div>
<div class="w-full pt-1.5 md:w-75">
<UFormField
:error="fieldError(site)"
:help="help(site)"
:ui="{
container: 'mt-0',
help: 'mt-1.5 text-xs/4.5 text-pretty text-muted',
error: 'mt-1.5 flex gap-1.5 text-xs/4.5 text-error',
}"
>
<LocaleInput
v-if="stage === 'done' || stage === 'inProgress'"
:model-value="shown(site)"
readonly
:flat="stage === 'done'"
:aria-label="t('Language for {0}', [site.name])"
/>
<LocaleSelect
v-else
:model-value="page.choices.value[site.key]"
:items="items"
:placeholder="t('Choose a language')"
:aria-label="t('Language for {0}', [site.name])"
:invalid="fieldError(site) !== undefined"
@update:model-value="(lang) => page.choose(site.key, lang)"
/>
<template v-if="fieldError(site)" #error>
<UIcon
name="i-lucide-circle-x"
class="mt-0.5 size-3.5 shrink-0"
/>
<span>{{ fieldError(site) }}</span>
</template>
</UFormField>
</div>
</div>
</div>
<div v-if="stage !== 'done'" class="flex flex-wrap items-center gap-2">
<LocaleButton
kind="primary"
:disabled="confirmDisabled"
:loading="stage === 'applying'"
@click="page.apply"
>
{{
stage === 'applying'
? t('Assigning…')
: t('Confirm and assign to {0} articles', [
page.articleTotal.value,
])
}}
</LocaleButton>
<span class="text-xs/5 text-muted">
{{
sites.length === 1
? t('Counts are articles in 1 site.')
: t('Counts are articles in {0} sites.', [sites.length])
}}
</span>
</div>
</template>
</template>
</div>
</DashboardShell>
</template>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, toRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import DashboardShell from '@/components/DashboardShell.vue'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleButton from '@/components/localization/LocaleButton.vue'
import LocaleInput from '@/components/localization/LocaleInput.vue'
import RetryAlert from '@/components/RetryAlert.vue'
import { usePublicUrlPage } from '@/composables/usePublicUrlPage'
import { PUBLIC_URL_PATTERN_MAX_LENGTH } from '@/lib/publicUrlPattern'
import { usePermissionsStore } from '@/stores/permissions'
import { useSessionStore } from '@/stores/session'
const route = useRoute()
const session = useSessionStore()
const permissions = usePermissionsStore()
const siteKey = computed(() => String(route.params.key ?? ''))
const page = usePublicUrlPage(toRef(siteKey))
const { loadState, value, saving, saveFailed, unset, canSave, errorMessage } = page
const sessionReady = computed(() => session.status === 'ready')
const owner = computed(() => permissions.can('settings.manage_sites'))
const siteName = computed(() => page.site.value?.name ?? siteKey.value)
const ready = computed(() => loadState.value === 'ready')
const invalid = computed(() => errorMessage.value !== undefined)
onMounted(page.load)
watch(siteKey, page.load)
const breadcrumb = computed(() => [
{ label: t('Settings'), to: '/settings' },
{ label: t('Sites'), to: '/settings/sites' },
{ label: siteName.value },
{ label: t('Public URL') },
])
</script>
<template>
<DashboardShell
compact-padding
:mobile-title="t('{0} · Public URL', [siteName])"
back-to="/settings/sites"
:back-label="t('Back to Sites')"
no-current-nav
>
<template #breadcrumb>
<UBreadcrumb
:items="breadcrumb"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<div v-if="sessionReady" class="flex flex-col gap-4">
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ t('Public URL') }}</h1>
<p class="text-pretty text-toned">
{{
t(
'Where {0} articles appear on your website. Used for canonical and alternate links.',
[siteName],
)
}}
</p>
</div>
<div
v-if="loadState === 'loading'"
class="h-44 bg-elevated"
role="status"
:aria-label="t('Loading')"
/>
<RetryAlert
v-else-if="loadState === 'error'"
:title="t('Couldn\'t load languages')"
:description="t('Check your connection and try again.')"
@retry="page.load"
/>
<template v-else-if="ready">
<LocaleAlert
v-if="!owner"
tone="neutral"
icon="i-lucide-lock"
:title="t('Only owners can change this')"
:description="t('You can read the pattern. Ask an owner to change it.')"
/>
<LocaleAlert
v-else-if="unset"
tone="info"
icon="i-lucide-info"
:title="t('Public URL not configured')"
:description="
t(
'Page, canonical and alternate addresses stay hidden until you add a pattern. Links by article ID still work.',
)
"
/>
<LocaleAlert
v-if="saveFailed"
tone="error"
icon="i-lucide-circle-x"
:title="t('Couldn\'t save languages')"
:description="t('Nothing changed. Your selection is kept. Try again.')"
/>
<UForm
:state="{ value }"
class="flex flex-col gap-3 border border-default bg-default p-4"
@submit="page.save"
>
<UFormField
:label="t('Public URL pattern')"
:error="errorMessage"
:help="
t(
'Placeholders: {lang} is the language code, such as fr or pt-BR. {slug} is the article address in that language.',
)
"
:ui="{
label: 'font-medium text-highlighted',
container: 'mt-1.5',
help: 'mt-1.5 text-xs/4.5 text-pretty text-muted',
error: 'mt-1.5 flex gap-1.5 text-xs/4.5 text-error',
}"
>
<LocaleInput
:model-value="value"
mono
:readonly="!owner"
:invalid="invalid"
placeholder="https://example.com/{lang}/{slug}"
:aria-label="t('Public URL pattern')"
:maxlength="PUBLIC_URL_PATTERN_MAX_LENGTH"
@update:model-value="page.edit"
/>
<template v-if="errorMessage" #error>
<UIcon name="i-lucide-circle-x" class="mt-0.5 size-3.5 shrink-0" />
<span>{{ errorMessage }}</span>
</template>
</UFormField>
<div
v-if="page.previewUrl.value"
class="flex flex-col gap-1"
data-testid="preview"
>
<span class="text-xs/5 text-muted">{{ page.previewLabel.value }}</span>
<span class="font-mono text-xs/5 wrap-anywhere text-highlighted">{{
page.previewUrl.value
}}</span>
</div>
<div v-if="owner" class="flex gap-2">
<LocaleButton
type="submit"
kind="primary"
:disabled="!canSave"
:loading="saving"
>
{{ t('Save') }}
</LocaleButton>
<LocaleButton :disabled="saving" @click="page.cancel">
{{ t('Cancel') }}
</LocaleButton>
</div>
</UForm>
</template>
</div>
</DashboardShell>
</template>
+333
View File
@@ -0,0 +1,333 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, toRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import AddLanguageDialog from '@/components/localization/AddLanguageDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import LanguageActionDialog from '@/components/localization/LanguageActionDialog.vue'
import LanguageRows from '@/components/localization/LanguageRows.vue'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleBadge from '@/components/localization/LocaleBadge.vue'
import LocaleButton from '@/components/localization/LocaleButton.vue'
import RetryAlert from '@/components/RetryAlert.vue'
import SocialLocaleDialog from '@/components/localization/SocialLocaleDialog.vue'
import { useSiteLanguagesPage } from '@/composables/useSiteLanguagesPage'
import { socialLocaleLabel } from '@/lib/languageCopy'
import { baseLanguage, derivedSocialLocale } from '@/lib/siteLanguages'
import { usePermissionsStore } from '@/stores/permissions'
import { useSessionStore } from '@/stores/session'
const SKELETON_ROWS = ['opacity-100', 'opacity-88', 'opacity-76', 'opacity-64']
const route = useRoute()
const session = useSessionStore()
const permissions = usePermissionsStore()
const siteKey = computed(() => String(route.params.key ?? ''))
const page = useSiteLanguagesPage(toRef(siteKey))
const { loadState, data, dialog, banner, failure, saving, name } = page
const sessionReady = computed(() => session.status === 'ready')
const owner = computed(() => permissions.can('settings.manage_sites'))
const siteName = computed(() => page.siteName.value ?? siteKey.value)
const needsSetup = computed(() => data.value?.needs_setup === true)
const ready = computed(() => loadState.value === 'ready' && data.value !== null)
const rows = computed(() => data.value?.languages ?? [])
const single = computed(() => rows.value.length === 1 && rows.value[0]!.enabled)
const setupPath = computed(
() => `/settings/sites/${encodeURIComponent(siteKey.value)}/languages/setup`,
)
const defaultName = computed(() => name(data.value?.default ?? ''))
const dialogLanguage = computed(() => {
const current = dialog.value
return current && 'lang' in current ? page.languageOf(current.lang) : undefined
})
onMounted(page.load)
watch(siteKey, page.load)
const breadcrumb = computed(() => [
{ label: t('Settings'), to: '/settings' },
{ label: t('Sites'), to: '/settings/sites' },
{ label: siteName.value },
{ label: t('Languages') },
])
</script>
<template>
<DashboardShell
compact-padding
:mobile-title="t('{0} · Languages', [siteName])"
back-to="/settings/sites"
:back-label="t('Back to Sites')"
no-current-nav
>
<template #breadcrumb>
<UBreadcrumb
:items="breadcrumb"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<div v-if="sessionReady" class="flex flex-col gap-4">
<div class="flex flex-col gap-3 md:flex-row md:items-start">
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ t('Languages') }}</h1>
<p class="text-pretty text-toned">
{{
t(
'Content languages for {0}. Each article version is written in one language.',
[siteName],
)
}}
</p>
</div>
<LocaleButton
v-if="owner && ready && !needsSetup"
kind="primary"
icon="i-lucide-plus"
class="max-md:self-start"
@click="page.openDialog({ kind: 'add' })"
>
{{ t('Add language') }}
</LocaleButton>
</div>
<div
v-if="loadState === 'loading'"
class="flex flex-col gap-2.5"
role="status"
:aria-label="t('Loading')"
>
<div
v-for="(fade, index) in SKELETON_ROWS"
:key="index"
class="h-11 bg-elevated max-md:h-14"
:class="fade"
/>
</div>
<RetryAlert
v-else-if="loadState === 'error'"
:title="t('Couldn\'t load languages')"
:description="t('Check your connection and try again.')"
@retry="page.load"
/>
<template v-else-if="ready && data">
<LocaleAlert
v-if="!owner"
tone="neutral"
icon="i-lucide-lock"
:title="t('Only owners can change languages')"
:description="
t(
'You can see which languages are enabled, their social locale and how many versions each has. Ask an owner to change them.',
)
"
/>
<LocaleAlert
v-if="needsSetup"
tone="info"
icon="i-lucide-info"
:title="t('Set up languages')"
:description="
t(
'Existing articles don\'t have a language yet. Confirm or change the language for each site.',
)
"
>
<template v-if="owner" #actions>
<LocaleButton small :to="setupPath">{{
t('Set up languages')
}}</LocaleButton>
</template>
</LocaleAlert>
<LocaleAlert
v-if="banner?.kind === 'disableDefault'"
tone="warning"
icon="i-lucide-triangle-alert"
:title="t('{0} is the default', [name(banner.lang)])"
:description="
t('Choose another default first, then disable {0}.', [name(banner.lang)])
"
/>
<LocaleAlert
v-else-if="banner?.kind === 'disableLast'"
tone="warning"
icon="i-lucide-triangle-alert"
:title="t('At least one language must stay enabled')"
:description="t('{0} needs a language for new articles.', [siteName])"
/>
<LocaleAlert
v-else-if="banner?.kind === 'stale'"
tone="error"
icon="i-lucide-circle-x"
:title="t('Languages changed. Reload.')"
:description="
t(
'Another owner changed the languages for {0} while this page was open. Reload to see the current list. Your change wasn\'t applied.',
[siteName],
)
"
>
<template #actions>
<LocaleButton small @click="page.reload">{{ t('Reload') }}</LocaleButton>
</template>
</LocaleAlert>
<LocaleAlert
v-else-if="banner?.kind === 'failed'"
tone="error"
icon="i-lucide-circle-x"
:title="
banner.action === 'default'
? t('Couldn\'t change the default language')
: t('Couldn\'t remove {0}', [name(banner.lang)])
"
:description="
banner.action === 'default'
? t('Nothing changed. {0} is still the default. Try again.', [
defaultName,
])
: t('Nothing changed. {0} is still listed. Try again.', [
name(banner.lang),
])
"
>
<template #actions>
<LocaleButton small @click="page.retryBanner">{{
t('Try again')
}}</LocaleButton>
</template>
</LocaleAlert>
<div
v-if="single"
class="flex flex-col gap-3 border border-default bg-default p-4"
data-testid="single-language"
>
<span class="font-semibold text-highlighted">{{
t('{0} uses one language', [siteName])
}}</span>
<span class="text-toned">{{
t(
'New articles are written in {0}. Add a language to write translations and publish them separately.',
[name(rows[0]!.lang)],
)
}}</span>
<div class="flex flex-wrap items-center gap-2.5">
<LocaleBadge tone="info" icon="i-lucide-star">{{
t('Default')
}}</LocaleBadge>
<span class="font-medium text-highlighted">{{ name(rows[0]!.lang) }}</span>
<span class="font-mono text-xs/5 text-muted">{{ rows[0]!.lang }}</span>
<span class="text-xs/5 text-muted">{{ t('Social locale') }}</span>
<span class="font-mono text-xs text-highlighted">{{
socialLocaleLabel(rows[0]!)
}}</span>
</div>
</div>
<LanguageRows
v-else-if="rows.length > 0"
:languages="rows"
:owner="owner"
:name-of="name"
@edit-social="(lang) => page.openDialog({ kind: 'social', lang })"
@reenable="(lang) => page.openDialog({ kind: 'reenable', lang })"
@make-default="(lang) => page.openDialog({ kind: 'default', lang })"
@disable="page.openDisable"
@remove="page.openRemove"
/>
<LocaleAlert
v-if="single && rows[0]!.lang.includes('-')"
tone="info"
icon="i-lucide-info"
:title="t('Regional language kept: {0}', [rows[0]!.lang])"
:description="
t(
'Setup kept the full site locale, so the default content language is {0}, not plain {1}. The site locale still formats dates and numbers. Social locale: {2}, taken from the language code.',
[
name(rows[0]!.lang),
name(baseLanguage(rows[0]!.lang)),
socialLocaleLabel({
...rows[0]!,
social_locale_state: derivedSocialLocale(rows[0]!.lang)
? 'derived'
: 'not_set',
}),
],
)
"
/>
<p
v-if="!single && owner && rows.length > 0"
class="text-xs/5 text-pretty text-muted"
>
{{
t(
'Disabling a language keeps its versions readable and editable, and published ones stay online. Drafts can be published again after you re-enable it. Social locale: Derived comes from the language code, Set is chosen by an owner, Not set leaves og:locale out.',
)
}}
</p>
</template>
</div>
<AddLanguageDialog
v-if="dialog?.kind === 'add' && data"
:languages="data"
:site-name="siteName"
:pending="saving"
:failure="failure"
:name-of="name"
@close="page.closeDialog"
@confirm="page.addLanguage"
@reload="page.reload"
/>
<LanguageActionDialog
v-else-if="
dialog &&
dialogLanguage &&
(dialog.kind === 'default' ||
dialog.kind === 'disable' ||
dialog.kind === 'reenable' ||
dialog.kind === 'remove' ||
dialog.kind === 'removeBlocked')
"
:kind="dialog.kind"
:language="dialogLanguage"
:name="name(dialog.lang)"
:pending="saving"
:failure="failure"
@close="page.closeDialog"
@reload="page.reload"
@confirm="
dialog.kind === 'default'
? page.makeDefault(dialog.lang)
: dialog.kind === 'disable'
? page.disable(dialog.lang)
: dialog.kind === 'reenable'
? page.reenable(dialog.lang)
: dialog.kind === 'remove'
? page.remove(dialog.lang)
: page.disableInstead(dialog.lang)
"
/>
<SocialLocaleDialog
v-else-if="dialog?.kind === 'social' && dialogLanguage"
:language="dialogLanguage"
:name="name(dialog.lang)"
:pending="saving"
:failure="failure"
@close="page.closeDialog"
@reload="page.reload"
@confirm="(value) => page.saveSocialLocale(dialogLanguage!.lang, value)"
/>
</DashboardShell>
</template>
+18 -1
View File
@@ -2,7 +2,7 @@
import { t, formatNumber } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useLocalizedOverlay } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
@@ -36,6 +36,7 @@ const INITIAL_SKELETON_ROWS = 3
type PanelTarget = { mode: 'add' } | { mode: 'edit'; site: Site }
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const permissions = usePermissionsStore()
const overlay = useLocalizedOverlay()
@@ -371,6 +372,10 @@ function deleteHint(site: Site) {
return site.is_default ? copy('defaultSiteBlocked') : undefined
}
function siteSubPath(site: Site, page: 'languages' | 'public-url') {
return `/settings/sites/${encodeURIComponent(site.key)}/${page}`
}
function menuItems(site: Site): DirectoryMenuItem[] {
const items: DirectoryMenuItem[] = [
{
@@ -379,6 +384,18 @@ function menuItems(site: Site): DirectoryMenuItem[] {
icon: 'i-lucide-pencil',
onSelect: (trigger) => openEdit(site, trigger),
},
{
key: 'languages',
label: t('Languages'),
icon: 'i-lucide-languages',
onSelect: () => router.push(siteSubPath(site, 'languages')),
},
{
key: 'public-url',
label: t('Public URL'),
icon: 'i-lucide-link',
onSelect: () => router.push(siteSubPath(site, 'public-url')),
},
]
if (!site.is_default) {
items.push({
@@ -382,11 +382,11 @@ describe('8d More endpoints than the cap', () => {
expect(options().at(-1)!.getAttribute('aria-label')).toBe(
'Section Errors. Status codes and messages',
)
expect(document.body.textContent).toContain('24 results')
expect(document.body.textContent).toContain('25 results')
expect(document.body.textContent).toContain(
'17 more endpoints. Keep typing to narrow the list.',
'18 more endpoints. Keep typing to narrow the list.',
)
expect(liveRegion()).toBe('24 results, showing 7')
expect(liveRegion()).toBe('25 results, showing 7')
})
})
@@ -441,7 +441,7 @@ describe('8f Session-only route', () => {
expect(path.classList.contains('font-normal')).toBe(true)
expect(route!.innerHTML).toContain('border-dashed')
expect(section!.getAttribute('aria-label')).toBe(
'Section Session only. 37 routes, not available to API keys',
'Section Session only. 40 routes, not available to API keys',
)
expect(section!.querySelector('svg')).not.toBeNull()
expect(document.body.textContent).toContain('2 results')
@@ -181,7 +181,7 @@ describe('Default, every card collapsed', () => {
['entries', 'Entries', '5 routes', 5],
['builtin', 'Built-in types', '2 routes', 2],
['media', 'Media', '11 routes', 11],
['sites', 'Sites and authors', '2 routes', 2],
['sites', 'Sites and authors', '3 routes', 3],
['users', 'Users and invites', '6 routes', 6],
['settings', 'Settings', '1 route', 1],
])('titles the %s group %s with %s', async (id, title, count, cards) => {
@@ -505,17 +505,17 @@ describe('Authentication', () => {
})
describe('Session only', () => {
it('renders the banner, 37 rows without expand controls and the 403 chips', async () => {
it('renders the banner, 40 rows without expand controls and the 403 chips', async () => {
const wrapper = await mountIntegration()
const session = section(wrapper, 'session')
expect(session.get('h2').text()).toBe('Session only')
expect(session.text()).toContain('37 routes, not available to API keys')
expect(session.text()).toContain('40 routes, not available to API keys')
expect(session.text()).toContain(
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* and /system/* answer 403 "This action needs a real session."',
)
const rows = session.findAll('[data-session-row]')
expect(rows).toHaveLength(37)
expect(rows).toHaveLength(40)
expect(session.findAll('[data-session-row] button')).toHaveLength(0)
expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0)
@@ -535,10 +535,13 @@ describe('Session only', () => {
'PATCH /v1/site/{id}',
'PATCH /v1/site/{id}/default',
'DELETE /v1/site/{id}',
'PUT /v1/site/{key}/languages',
'POST /v1/author/',
'PATCH /v1/author/{id}',
'DELETE /v1/author/{id}',
'POST /v1/author/me',
'GET /v1/language/setup',
'POST /v1/language/setup',
'GET /v1/image-variants/',
'POST /v1/image-variants/',
'PUT /v1/image-variants/{name}',
@@ -0,0 +1,327 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import USelect from '@nuxt/ui/components/Select.vue'
import LocaleSelect from '@/components/localization/LocaleSelect.vue'
import { initializeLocale } from '@/i18n'
import { AVAILABLE, SITES } from './localizationData'
import {
WRITE_FAILS,
callsTo,
control,
deferred,
failWith,
kit,
mountRoute,
press,
settle,
text,
unmountRoute,
type Route,
} from './localizationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => initializeLocale())
afterEach(unmountRoute)
const JOURNAL = {
key: 'journal',
name: 'Journal',
locale: 'en',
article_count: 8,
suggestion: { lang: 'en', substituted: false },
}
const NOTES = { key: 'notes', name: 'Notes', locale: null, article_count: 3, suggestion: null }
const ARCHIVE = {
key: 'archive',
name: 'Archive',
locale: 'pt-BR',
article_count: 5,
suggestion: { lang: 'pt-BR', substituted: false },
}
const GALLERY = {
key: 'gallery',
name: 'Gallery',
locale: 'en-GB',
article_count: 0,
suggestion: { lang: 'en-GB', substituted: false },
}
const BELGIAN = {
key: 'belgium',
name: 'Belgium',
locale: 'fr-BE',
article_count: 2,
suggestion: { lang: 'fr', substituted: true },
}
const POST = 'POST /v1/language/setup'
function pending(...sites: unknown[]) {
return { sites, available: AVAILABLE }
}
function open(setup: unknown, extra: Record<string, Route> = {}, role = 'owner', key = 'journal') {
return mountRoute(`/settings/sites/${key}/languages/setup`, {
role,
routes: { 'GET /v1/site/': SITES, 'GET /v1/language/setup': setup, ...extra },
})
}
async function choose(siteKey: string, label: string, tag: string) {
const selects = kit.wrapper!.findAllComponents(LocaleSelect)
const target = selects.find((select) => select.props('ariaLabel') === label)
if (!target) {
throw new Error(`No select ${label} for ${siteKey}`)
}
target.vm.$emit('update:modelValue', tag)
await settle()
}
function confirmButton() {
return control(/^Confirm and assign to/) as HTMLButtonElement
}
describe('loc.l2.ready: Ready to assign', () => {
beforeEach(async () => {
await open(pending(JOURNAL, NOTES, ARCHIVE))
})
it('shows the heading, the info alert and one row per pending site', () => {
const page = text()
expect(page).toContain('Set article languages')
expect(page).toContain(
"Existing articles don't have a language yet. Confirm or change the language for each site.",
)
expect(page).toContain("This labels articles. It doesn't translate them.")
expect(page).toContain(
"Each existing article becomes the only version of its own article group. Each site's language starts from its locale with the region kept: pt-BR stays Portuguese (Brazil). Nothing is applied until you confirm.",
)
expect(page).toContain('8 articles · Site locale en')
expect(page).toContain('3 articles · No site locale')
expect(page).toContain('5 articles · Site locale pt-BR')
expect(page).toContain('Seeded from site locale en. Confirm or change.')
expect(page).toContain('Seeded from site locale pt-BR. Confirm or change.')
expect(page).toContain('Required before you can apply.')
})
it('seeds Journal and Archive and leaves Notes empty', () => {
const selects = kit.wrapper!.findAllComponents(USelect)
const values = selects.map((select) => select.props('modelValue'))
expect(values).toEqual(['en', undefined, 'pt-BR'])
})
it('keeps the confirm button disabled until every row has a language, with the total', async () => {
expect(confirmButton().textContent?.trim()).toBe('Confirm and assign to 16 articles')
expect(confirmButton().disabled).toBe(true)
expect(text()).toContain('Counts are articles in 3 sites.')
await choose('notes', 'Language for Notes', 'en')
expect(confirmButton().disabled).toBe(false)
})
})
describe('The page sends one request for all rows', () => {
it('posts Journal en, Notes en, Archive pt-BR and shows Done with the count', async () => {
await open(pending(JOURNAL, NOTES, ARCHIVE), {
[POST]: { assigned: 16, skipped: [], sites: [] },
})
await choose('notes', 'Language for Notes', 'en')
await press(/^Confirm and assign to/)
expect(callsTo('POST', '/v1/language/setup')).toHaveLength(1)
expect(callsTo('POST', '/v1/language/setup')[0]!.body).toEqual({
sites: [
{ key: 'journal', lang: 'en' },
{ key: 'notes', lang: 'en' },
{ key: 'archive', lang: 'pt-BR' },
],
})
})
})
describe('loc.l2.noLocale: No-locale site', () => {
it('shows the error alert and a disabled confirm button', async () => {
await open(pending(NOTES))
expect(text()).toContain('Choose a language for Notes')
expect(text()).toContain(
'Notes has no site locale, so there is nothing to start from. Choose the language its articles are written in.',
)
expect(confirmButton().disabled).toBe(true)
})
})
describe('loc.l2.empty: Site with no articles', () => {
it('shows the page as Languages with the card, the seeded field and a Save button', async () => {
await open(pending(GALLERY), {}, 'owner', 'gallery')
const page = text()
expect(page).toContain('Languages')
expect(page).toContain('Content languages for Gallery.')
expect(page).toContain('Confirm the default language')
expect(page).toContain(
'Gallery has no articles yet. New articles will be written in this language.',
)
expect(page).toContain('Default content language')
expect(page).toContain(
'Seeded from site locale en-GB. The full locale is kept. Confirm or change it.',
)
expect(kit.wrapper!.findComponent(USelect).props('modelValue')).toBe('en-GB')
expect((control('Save') as HTMLButtonElement).disabled).toBe(false)
})
it('posts the single site on Save', async () => {
await open(
pending(GALLERY),
{ [POST]: { assigned: 0, skipped: [], sites: [] } },
'owner',
'gallery',
)
await press('Save')
expect(callsTo('POST', '/v1/language/setup')[0]!.body).toEqual({
sites: [{ key: 'gallery', lang: 'en-GB' }],
})
})
})
describe('loc.l2.applying: Applying', () => {
it('shows the busy button and the alert while the POST is in flight', async () => {
const inflight = deferred<unknown>()
await open(pending(JOURNAL, NOTES, ARCHIVE), { [POST]: () => inflight.promise })
await choose('notes', 'Language for Notes', 'en')
await press(/^Confirm and assign to/)
expect(text()).toContain('Assigning languages')
expect(text()).toContain(
"Articles created while this runs get the site's default language.",
)
const busy = control(/^Assigning…/) as HTMLButtonElement
expect(busy.disabled).toBe(true)
inflight.resolve({ assigned: 16, skipped: [], sites: [] })
})
})
describe('loc.l2.inProgress: Setup already running', () => {
beforeEach(async () => {
await open(pending(JOURNAL, NOTES, ARCHIVE), { [POST]: failWith(409, 'setup_in_progress') })
await choose('notes', 'Language for Notes', 'en')
await press(/^Confirm and assign to/)
})
it('explains it, makes the rows read-only and disables confirm', () => {
expect(text()).toContain('Setup is already running')
expect(text()).toContain(
"Another owner started it. Languages appear here when it finishes. Articles created meanwhile get each site's default language. Starting it again is safe: sites that already have a language are skipped.",
)
expect(kit.wrapper!.findAllComponents(USelect)).toHaveLength(0)
expect(confirmButton().disabled).toBe(true)
})
it('repeats GET /v1/language/setup on Check again', async () => {
await press('Check again')
expect(callsTo('GET', '/v1/language/setup')).toHaveLength(2)
})
})
describe('loc.l2.done: Done', () => {
it('shows the success alert with the count and Open Articles, and no confirm button', async () => {
await open(pending(JOURNAL, NOTES, ARCHIVE), {
[POST]: { assigned: 16, skipped: [], sites: [] },
})
await choose('notes', 'Language for Notes', 'en')
await press(/^Confirm and assign to/)
expect(text()).toContain('Languages assigned')
expect(text()).toContain(
'16 articles now have a language. Nothing was translated, and no articles were grouped.',
)
const link = Array.from(document.querySelectorAll('a')).find(
(anchor) => anchor.textContent?.trim() === 'Open Articles',
)
expect(link?.getAttribute('href')).toBe('/article')
expect(control(/^Confirm and assign to/)).toBeUndefined()
})
})
describe('loc.l2.fail: Failed', () => {
it('keeps the choices and retries with Try again', async () => {
await open(pending(JOURNAL, NOTES, ARCHIVE), { [POST]: WRITE_FAILS })
await choose('notes', 'Language for Notes', 'en')
await press(/^Confirm and assign to/)
expect(text()).toContain('Nothing was changed')
expect(text()).toContain(
'The update failed, so no article has a language yet. Your choices are kept. Try again. Running it twice is safe.',
)
expect(kit.wrapper!.findAllComponents(USelect).map((s) => s.props('modelValue'))).toEqual([
'en',
'en',
'pt-BR',
])
expect(confirmButton().disabled).toBe(false)
await press('Try again')
expect(callsTo('POST', '/v1/language/setup')).toHaveLength(2)
})
})
describe('Nothing pending', () => {
it('shows Done without an article count', async () => {
await open(pending())
expect(text()).toContain('Languages assigned')
expect(text()).not.toMatch(/\d+ articles? now has?\b/)
expect(text()).toContain('Nothing was translated, and no articles were grouped.')
})
it('shows the same once Check again finds nothing pending', async () => {
let reads = 0
await open(pending(JOURNAL), {
'GET /v1/language/setup': () => (reads++ === 0 ? pending(JOURNAL) : pending()),
[POST]: failWith(409, 'setup_in_progress'),
})
await press(/^Confirm and assign to/)
await press('Check again')
expect(text()).toContain('Languages assigned')
})
})
describe('Substituted seed', () => {
it('shows the seeded broader tag with the broader wording', async () => {
await open(pending(BELGIAN))
expect(kit.wrapper!.findComponent(USelect).props('modelValue')).toBe('fr')
expect(text()).toContain(
'Only the broader language French (fr) is supported. Pages and the API will say fr, not fr-BE.',
)
})
})
describe('Unknown or already complete :key', () => {
it('lists the pending sites and links back to the Languages page of a known key', async () => {
await open(pending(JOURNAL, NOTES), {}, 'owner', 'archive')
expect(text()).toContain('Journal')
const back = document.querySelector('a[href="/settings/sites/archive/languages"]')
expect(back).not.toBeNull()
})
it('falls back to Settings, Sites for an unknown key', async () => {
await open(pending(JOURNAL), {}, 'owner', 'nowhere')
expect(document.querySelector('a[href="/settings/sites/nowhere/languages"]')).toBeNull()
expect(document.querySelector('a[href="/settings/sites"]')).not.toBeNull()
})
})
describe('Route guard', () => {
it('keeps a reader out of the page and sends nothing', async () => {
await open(pending(JOURNAL), {}, 'viewer')
expect(text()).toContain("You don't have access to this page")
expect(callsTo('GET', '/v1/language/setup')).toHaveLength(0)
})
})
@@ -0,0 +1,273 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { initializeLocale } from '@/i18n'
import { SITES, siteRow } from './localizationData'
import {
callsTo,
control,
failWith,
mountRoute,
press,
settle,
text,
toastText,
typeInto,
unmountRoute,
type Route,
} from './localizationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => initializeLocale())
afterEach(unmountRoute)
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
const TITLE = 'Comment nous chiffrons une rénovation de salle de bain'
const SLUG = 'comment-nous-chiffrons-une-renovation'
function sites(pattern: string | null) {
return [{ ...SITES[0]!, public_url_pattern: pattern }, ...SITES.slice(1)]
}
const ARTICLES = {
items: [{ id: 14, title: TITLE, slug: SLUG, lang: 'fr', body: '', is_online: true }],
total: 1,
page: 1,
size: 1,
counts: { all: 1, online: 1, draft: 0 },
}
const NO_ARTICLES = { ...ARTICLES, items: [], total: 0 }
async function open(
pattern: string | null,
extra: Record<string, Route> = {},
role = 'owner',
articles: unknown = ARTICLES,
) {
await mountRoute('/settings/sites/journal/public-url', {
role,
routes: {
'GET /v1/site/': sites(pattern),
'GET /v1/article/': articles,
...extra,
},
})
}
function input() {
return document.querySelector<HTMLInputElement>('input')!
}
function save() {
return control('Save') as HTMLButtonElement
}
describe('loc.l1b.set: Set', () => {
it('shows the saved pattern, the help, the preview and Save and Cancel', async () => {
await open(PATTERN)
const page = text()
expect(page).toContain('Public URL')
expect(page).toContain(
'Where Journal articles appear on your website. Used for canonical and alternate links.',
)
expect(page).toContain('Public URL pattern')
expect(input().value).toBe(PATTERN)
expect(page).toContain(
'Placeholders: {lang} is the language code, such as fr or pt-BR. {slug} is the article address in that language.',
)
expect(page).toContain(`Preview with "${TITLE}" in French`)
expect(page).toContain(`https://journal.example.com/fr/${SLUG}`)
expect(save()).toBeTruthy()
expect(control('Cancel')).toBeTruthy()
expect(callsTo('GET', '/v1/article/')[0]!.query.get('site')).toBe('journal')
})
})
describe('loc.l1b.editing: Editing', () => {
it('makes the preview follow the field on each change', async () => {
await open(PATTERN)
await typeInto(input(), 'https://journal.example.com/{lang}/articles/{slug}')
expect(text()).toContain(`https://journal.example.com/fr/articles/${SLUG}`)
await typeInto(input(), 'https://journal.example.com/{slug}')
expect(text()).toContain(`https://journal.example.com/${SLUG}`)
expect(text()).not.toContain('/fr/')
})
})
describe.each([
[
'loc.l1b.unknown: Unknown placeholder',
'https://journal.example.com/{language}/{slug}',
'Unknown placeholder {language}. Use {lang} and {slug}.',
],
[
'loc.l1b.scheme: Bad scheme',
'ftp://journal.example.com/{lang}/{slug}',
'Start with https:// or http://.',
],
[
'loc.l1b.encoded: Not encoded',
'https://journal.example.com/my pages/{lang}/{slug}',
'Remove spaces and characters that need encoding. Use letters, digits, - and _.',
],
[
'loc.l1b.noSlug: Missing {slug}',
'https://journal.example.com/{lang}',
'Add {slug} so each article has its own address.',
],
])('%s', (_state, typed, message) => {
it('shows the error, hides the preview and disables Save', async () => {
await open(PATTERN)
await typeInto(input(), typed)
expect(text()).toContain(message)
expect(document.querySelector('[data-testid="preview"]')).toBeNull()
expect(save().disabled).toBe(true)
save().click()
await settle()
expect(callsTo('PATCH', '/v1/site/1')).toHaveLength(0)
})
})
describe('Client and server agree', () => {
it.each([
['invalid_scheme', 'Start with https:// or http://.'],
['invalid_host', 'Start with https:// or http://.'],
['missing_slug', 'Add {slug} so each article has its own address.'],
['unknown_placeholder', 'Unknown placeholder. Use {lang} and {slug}.'],
[
'not_encoded',
'Remove spaces and characters that need encoding. Use letters, digits, - and _.',
],
])('maps the server code %s to its message', async (code, message) => {
await open(PATTERN, { 'PATCH /v1/site/1': failWith(422, code) })
await typeInto(input(), 'https://journal.example.com/ok/{slug}')
save().click()
await settle()
expect(text()).toContain(message)
expect(save().disabled).toBe(true)
})
})
describe('loc.l1b.saved: Saved', () => {
it('sends the trimmed pattern, shows the toast and keeps the saved value', async () => {
const updated = { ...sites(PATTERN)[0]!, public_url_pattern: PATTERN }
await open(null, { 'PATCH /v1/site/1': updated })
await typeInto(input(), ` ${PATTERN} `)
await press('Save')
expect(callsTo('PATCH', '/v1/site/1')[0]!.body).toEqual({ public_url_pattern: PATTERN })
expect(toastText()).toContain('Public URL saved')
expect(toastText()).toContain('Journal pages now use this pattern.')
expect(input().value).toBe(PATTERN)
})
})
describe('loc.l1b.unset: Unset', () => {
it('shows the info alert, the placeholder, no preview and a disabled Save', async () => {
await open(null)
const page = text()
expect(page).toContain('Public URL not configured')
expect(page).toContain(
'Page, canonical and alternate addresses stay hidden until you add a pattern. Links by article ID still work.',
)
expect(input().placeholder).toBe('https://example.com/{lang}/{slug}')
expect(document.querySelector('[data-testid="preview"]')).toBeNull()
expect(save().disabled).toBe(true)
})
})
describe('loc.l1b.viewOnly: Read-only view', () => {
it('shows the pattern read-only with no Save or Cancel', async () => {
await open(PATTERN, {}, 'viewer')
expect(text()).toContain('Only owners can change this')
expect(text()).toContain('You can read the pattern. Ask an owner to change it.')
expect(input().readOnly).toBe(true)
expect(input().value).toBe(PATTERN)
expect(control('Save')).toBeUndefined()
expect(control('Cancel')).toBeUndefined()
})
})
describe('Clearing the field', () => {
it('sends null and shows the Unset state', async () => {
const cleared = { ...sites(PATTERN)[0]!, public_url_pattern: null }
await open(PATTERN, { 'PATCH /v1/site/1': cleared })
await typeInto(input(), '')
await press('Save')
expect(callsTo('PATCH', '/v1/site/1')[0]!.body).toEqual({ public_url_pattern: null })
expect(input().value).toBe('')
expect(text()).toContain('Public URL not configured')
})
})
describe('Cancel', () => {
it('returns the field to the saved value and sends nothing', async () => {
await open(PATTERN)
await typeInto(input(), 'https://other.example.com/{slug}')
await press('Cancel')
expect(input().value).toBe(PATTERN)
expect(callsTo('PATCH', '/v1/site/1')).toHaveLength(0)
})
})
describe('Preview of a site without articles', () => {
it('shows no preview line', async () => {
await open(PATTERN, {}, 'owner', NO_ARTICLES)
expect(document.querySelector('[data-testid="preview"]')).toBeNull()
expect(input().value).toBe(PATTERN)
})
it('uses the default language of the site when the article has none yet', async () => {
await open(
PATTERN,
{
'GET /v1/site/journal/languages': { default: 'en', languages: [], available: [] },
},
'owner',
{ ...ARTICLES, items: [{ ...ARTICLES.items[0]!, lang: '' }] },
)
expect(text()).toContain(`https://journal.example.com/en/${SLUG}`)
})
})
describe('Failures with no frame', () => {
it('reuses the Languages alert copy when the load fails', async () => {
await mountRoute('/settings/sites/journal/public-url', {
routes: { 'GET /v1/site/': failWith(500, 'Internal server error') },
})
expect(text()).toContain("Couldn't load languages")
expect(text()).toContain('Check your connection and try again.')
})
it('reuses the Languages alert copy when the save fails with a server error', async () => {
await open(PATTERN, { 'PATCH /v1/site/1': failWith(500, 'Internal server error') })
await typeInto(input(), 'https://journal.example.com/{slug}')
await press('Save')
expect(text()).toContain("Couldn't save languages")
expect(text()).toContain('Nothing changed. Your selection is kept. Try again.')
expect(input().value).toBe('https://journal.example.com/{slug}')
})
it('shows the load failure for a site that does not exist', async () => {
await mountRoute('/settings/sites/nowhere/public-url', {
routes: { 'GET /v1/site/': [siteRow(9, 'other', 'Other')] },
})
expect(text()).toContain("Couldn't load languages")
})
})
@@ -106,7 +106,7 @@ describe('Search and sharing group', () => {
.map((item) => item.props('endpoint').path),
).toContain('/v1/article/{id}/seo/check')
expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
expect(section(wrapper, 'sites').text()).toContain('2 routes')
expect(section(wrapper, 'sites').text()).toContain('3 routes')
})
it('describes the resolved SEO object and shows the real error rows', async () => {
@@ -0,0 +1,459 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import USelect from '@nuxt/ui/components/Select.vue'
import { initializeLocale } from '@/i18n'
import { ATELIER, JOURNAL, JOURNAL_ROWS, SITES, languagesBody } from './localizationData'
import {
WRITE_FAILS,
callsTo,
chooseAction,
control,
dialogBox,
dialogText,
failWith,
kit,
mountRoute,
press,
settle,
text,
toastText,
typeInto,
unmountRoute,
type Route,
} from './localizationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => initializeLocale())
afterEach(unmountRoute)
const PUT = 'PUT /v1/site/journal/languages'
const PATH = '/v1/site/journal/languages'
function journal(extra: Record<string, Route> = {}, data: unknown = JOURNAL) {
return mountRoute('/settings/sites/journal/languages', {
routes: { 'GET /v1/site/': SITES, 'GET /v1/site/journal/languages': data, ...extra },
})
}
async function chooseLanguage(tag: string) {
kit.wrapper!.findComponent(USelect).vm.$emit('update:modelValue', tag)
await settle()
}
function sentBody(index = 0) {
return callsTo('PUT', PATH)[index]!.body as {
revision: string
default: string
languages: { lang: string; enabled: boolean; social_locale: string | null }[]
}
}
describe('loc.l1.add: Add a language', () => {
it('opens the dialog with a read-only code and social locale for the chosen language', async () => {
await journal()
await press('Add language')
expect(dialogText()).toContain('Add a language')
expect(dialogText()).toContain(
'Choose a language for Journal. Regional variants such as English (United Kingdom) are separate entries.',
)
await chooseLanguage('pt-BR')
const box = dialogText()
expect(box).toContain('Language code')
expect(box).toContain('Used in the API and in page language metadata.')
expect(box).toContain('Social locale')
const readonly = Array.from(
dialogBox()!.querySelectorAll<HTMLInputElement>('input[readonly]'),
)
expect(readonly.map((input) => input.value)).toEqual(['pt-BR', 'Derived: pt_BR'])
expect(control('Cancel', dialogBox()!)).toBeTruthy()
expect(control('Add language', dialogBox()!)).toBeTruthy()
})
it('lists a disabled language as "Disabled — re-enable instead" and refuses it', async () => {
await journal()
await press('Add language')
const items = kit.wrapper!.findComponent(USelect).props('items') as {
value: string
label: string
disabled?: boolean
}[]
const spanish = items.find((item) => item.value === 'es')!
expect(spanish.label).toBe('Spanish · Disabled — re-enable instead')
expect(spanish.disabled).toBe(true)
expect(items.find((item) => item.value === 'fr')!.disabled).toBe(false)
})
})
describe('loc.l1.saved: Saved', () => {
it('sends the full state plus the new row, closes the dialog and shows the toast', async () => {
const added = languagesBody('journal-2', 'en', [
...JOURNAL_ROWS,
{ lang: 'pt-BR', versions: 0, published: 0, derived: true },
])
await journal({ [PUT]: added })
await press('Add language')
await chooseLanguage('pt-BR')
await press('Add language', dialogBox()!)
expect(sentBody()).toEqual({
revision: 'journal-1',
default: 'en',
languages: [
...JOURNAL_ROWS.map((row) => ({
lang: row.lang,
enabled: row.enabled ?? true,
social_locale: row.social ?? null,
})),
{ lang: 'pt-BR', enabled: true, social_locale: null },
],
})
expect(dialogBox()).toBeNull()
expect(toastText()).toContain('Portuguese (Brazil) added')
expect(toastText()).toContain('Writers can now create Portuguese (Brazil) versions.')
expect(text()).toContain('Portuguese (Brazil)')
})
})
describe('loc.l1.saveFail: Add failed', () => {
it('keeps the dialog and the selection and offers Try again', async () => {
await journal({ [PUT]: WRITE_FAILS })
await press('Add language')
await chooseLanguage('pt-BR')
await press('Add language', dialogBox()!)
expect(dialogBox()).not.toBeNull()
expect(dialogText()).toContain("Couldn't save languages")
expect(dialogText()).toContain('Nothing changed. Your selection is kept. Try again.')
expect(
Array.from(dialogBox()!.querySelectorAll<HTMLInputElement>('input[readonly]')).map(
(input) => input.value,
),
).toContain('pt-BR')
expect(control('Try again', dialogBox()!)).toBeTruthy()
await press('Try again', dialogBox()!)
expect(callsTo('PUT', PATH)).toHaveLength(2)
})
})
describe('loc.l1.adddup: Duplicate language', () => {
it('shows the field error, disables Add language and sends nothing', async () => {
await journal()
await press('Add language')
await chooseLanguage('fr')
expect(dialogText()).toContain('French is already enabled for Journal.')
const add = control('Add language', dialogBox()!) as HTMLButtonElement
expect(add.disabled).toBe(true)
add.click()
await settle()
expect(callsTo('PUT', PATH)).toHaveLength(0)
})
})
describe('loc.l1.broader: Only a broader tag supported', () => {
it('explains the substitution and sends pt only once the box is checked', async () => {
await journal({ [PUT]: JOURNAL })
await press('Add language')
await chooseLanguage('pt-PT')
expect(dialogText()).toContain("Portuguese (Portugal) isn't available")
expect(dialogText()).toContain(
"Only the broader language Portuguese (pt) is supported. Pages and the API will say pt, not pt-PT. Articles written in this language can't be told apart from other Portuguese.",
)
expect(dialogText()).toContain('Add Portuguese (pt) instead')
const add = control('Add Portuguese', dialogBox()!) as HTMLButtonElement
expect(add.disabled).toBe(true)
dialogBox()!.querySelector<HTMLElement>('[role="checkbox"]')!.click()
await settle()
expect((control('Add Portuguese', dialogBox()!) as HTMLButtonElement).disabled).toBe(false)
await press('Add Portuguese', dialogBox()!)
expect(sentBody().languages.at(-1)).toEqual({
lang: 'pt',
enabled: true,
social_locale: null,
})
})
})
describe('The row menu', () => {
it('offers Make default, Disable and Remove, and keeps Re-enable as a row button', async () => {
await journal()
await press('Actions for French')
const items = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map((item) =>
item.textContent?.trim(),
)
expect(items).toEqual(['Make default', 'Disable', 'Remove'])
})
it('offers only Remove on a disabled language', async () => {
await journal()
await press('Actions for Spanish')
const items = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map((item) =>
item.textContent?.trim(),
)
expect(items).toEqual(['Remove'])
})
})
describe('loc.l1.default: Make default', () => {
it('asks for confirmation, then sends the new default', async () => {
await journal({ [PUT]: JOURNAL })
await chooseAction('French', 'Make default')
expect(dialogText()).toContain('Make French the default language?')
expect(dialogText()).toContain(
"New articles start in the default language, and it is the first choice for each article's title in the list.",
)
expect(dialogText()).toContain('No content changes')
await press('Make French default', dialogBox()!)
expect(sentBody().default).toBe('fr')
expect(sentBody().revision).toBe('journal-1')
expect(dialogBox()).toBeNull()
})
it('loc.l1.defaultFail: keeps the dialog and shows the failure', async () => {
await journal({ [PUT]: WRITE_FAILS })
await chooseAction('French', 'Make default')
await press('Make French default', dialogBox()!)
expect(dialogText()).toContain("Couldn't change the default language")
expect(dialogText()).toContain('Nothing changed. Try again.')
expect(control('Try again', dialogBox()!)).toBeTruthy()
expect(text()).toContain('Nothing changed. English is still the default. Try again.')
})
})
describe('loc.l1.disable: Disable with content', () => {
it('names the versions and sends enabled false for German', async () => {
await journal({ [PUT]: JOURNAL })
await chooseAction('German', 'Disable')
expect(dialogText()).toContain('Disable German?')
expect(dialogText()).toContain('German is used by 4 versions: 1 draft and 3 published.')
expect(dialogText()).toContain('Nothing is deleted or unpublished')
expect(dialogText()).toContain(
"The 3 published German versions stay online. All 4 versions stay readable and editable. The draft can't be published until you re-enable German. Nobody can create new German versions.",
)
await press('Disable German', dialogBox()!)
expect(sentBody().languages.find((row) => row.lang === 'de')!.enabled).toBe(false)
expect(sentBody().languages.find((row) => row.lang === 'en')!.enabled).toBe(true)
})
})
describe('loc.l1.stale: Languages changed', () => {
it('shows the Reload alert and repeats the GET', async () => {
await journal({ [PUT]: failWith(409, 'languages_changed') })
await chooseAction('German', 'Disable')
await press('Disable German', dialogBox()!)
expect(text()).toContain('Languages changed. Reload.')
expect(text()).toContain(
"Another owner changed the languages for Journal while this page was open. Reload to see the current list. Your change wasn't applied.",
)
expect(dialogText()).toContain("Your change wasn't applied.")
await press('Reload', dialogBox()!)
expect(callsTo('GET', PATH)).toHaveLength(2)
expect(dialogBox()).toBeNull()
expect(text()).not.toContain('Languages changed. Reload.')
})
})
describe('Disable is blocked without a request', () => {
it('loc.lang.disableDefault: warns that English is the default', async () => {
await journal()
await chooseAction('English', 'Disable')
expect(text()).toContain('English is the default')
expect(text()).toContain('Choose another default first, then disable English.')
expect(dialogBox()).toBeNull()
expect(callsTo('PUT', PATH)).toHaveLength(0)
})
it('loc.lang.disableLast: warns that one language must stay enabled', async () => {
const solo = languagesBody('journal-solo', 'en', [
{ lang: 'en', versions: 7, published: 6 },
{ lang: 'fr', enabled: false, versions: 4, published: 2 },
])
await journal({}, solo)
await chooseAction('English', 'Disable')
expect(text()).toContain('At least one language must stay enabled')
expect(text()).toContain('Journal needs a language for new articles.')
expect(callsTo('PUT', PATH)).toHaveLength(0)
})
})
describe('loc.l1.reenable: Re-enable', () => {
it('asks first, then enables Spanish and shows the toast', async () => {
await journal({ [PUT]: JOURNAL })
await press('Re-enable', document.querySelector('[data-testid="language-row-es"]')!)
expect(dialogText()).toContain('Re-enable Spanish?')
expect(dialogText()).toContain(
'Writers can create Spanish versions again. The 1 existing Spanish version stays as it is. Nothing else changes.',
)
await press('Re-enable Spanish', dialogBox()!)
expect(sentBody().languages.find((row) => row.lang === 'es')!.enabled).toBe(true)
expect(toastText()).toContain('Spanish re-enabled')
expect(toastText()).toContain(
'Writers can create Spanish versions again. Drafts can be published.',
)
})
it('loc.l1.reenableFail: keeps the dialog and says Spanish is still disabled', async () => {
await journal({ [PUT]: WRITE_FAILS })
await press('Re-enable', document.querySelector('[data-testid="language-row-es"]')!)
await press('Re-enable Spanish', dialogBox()!)
expect(dialogText()).toContain("Couldn't re-enable Spanish")
expect(dialogText()).toContain('Nothing changed. Spanish is still disabled. Try again.')
expect(control('Try again', dialogBox()!)).toBeTruthy()
})
})
describe('loc.l1.remove: Remove (no content)', () => {
it('removes Italian after a confirmation with a danger button', async () => {
await journal({ [PUT]: JOURNAL })
await chooseAction('Italian', 'Remove')
expect(dialogText()).toContain('Remove Italian?')
expect(dialogText()).toContain('No article uses Italian. You can add it again later.')
await press('Remove Italian', dialogBox()!)
expect(sentBody().languages.map((row) => row.lang)).not.toContain('it')
expect(sentBody().languages).toHaveLength(5)
})
it('loc.l1.removeFail: keeps the dialog and says Italian is still listed', async () => {
await journal({ [PUT]: WRITE_FAILS })
await chooseAction('Italian', 'Remove')
await press('Remove Italian', dialogBox()!)
expect(dialogText()).toContain("Couldn't remove Italian")
expect(dialogText()).toContain('Nothing changed. Italian is still listed. Try again.')
expect(control('Try again', dialogBox()!)).toBeTruthy()
})
})
describe('loc.l1.removeBlocked: Remove blocked', () => {
it('explains that Spanish is used, offers Close and Disable Spanish, sends nothing', async () => {
await journal()
await chooseAction('Spanish', 'Remove')
expect(dialogText()).toContain("Spanish can't be removed")
expect(dialogText()).toContain(
'Spanish is used by 1 version. Disable the language to keep it, or delete it first.',
)
expect(control('Close', dialogBox()!)).toBeTruthy()
expect(control('Disable Spanish', dialogBox()!)).toBeTruthy()
expect(callsTo('PUT', PATH)).toHaveLength(0)
})
it('shows the same dialog and reloads the list when the server answers language_in_use', async () => {
let reads = 0
const refreshed = languagesBody('journal-2', 'en', [
...JOURNAL_ROWS.slice(0, 5),
{ lang: 'it', enabled: false, versions: 1, published: 0 },
])
await journal({
'GET /v1/site/journal/languages': () => (reads++ === 0 ? JOURNAL : refreshed),
[PUT]: failWith(409, 'language_in_use'),
})
await chooseAction('Italian', 'Remove')
await press('Remove Italian', dialogBox()!)
expect(dialogText()).toContain("Italian can't be removed")
expect(dialogText()).toContain('Italian is used by 1 version.')
expect(callsTo('GET', PATH)).toHaveLength(2)
})
})
describe('loc.l1.socialEdit: Social locale: edit', () => {
beforeEach(async () => {
await mountRoute('/settings/sites/atelier/languages', {
routes: {
'GET /v1/site/': SITES,
'GET /v1/site/atelier/languages': ATELIER,
'PUT /v1/site/atelier/languages': ATELIER,
},
})
await press('Edit social locale for French')
})
function field() {
return dialogBox()!.querySelector<HTMLInputElement>('input')!
}
it('opens the dialog with the current value and the format help', () => {
expect(dialogText()).toContain('Social locale for French')
expect(dialogText()).toContain(
'Used for og:locale on French pages. Leave it empty to omit the tag.',
)
expect(field().value).toBe('fr_FR')
expect(dialogText()).toContain(
'Format: language_REGION, for example fr_FR. Letters only, one underscore.',
)
expect(control('Cancel', dialogBox()!)).toBeTruthy()
expect(control('Save social locale', dialogBox()!)).toBeTruthy()
})
it('loc.l1.socialInvalid: shows the field error and disables the save button', async () => {
await typeInto(field(), 'fr-FR')
expect(dialogText()).toContain('Use letters and one underscore, for example fr_FR.')
expect((control('Save social locale', dialogBox()!) as HTMLButtonElement).disabled).toBe(
true,
)
expect(callsTo('PUT', '/v1/site/atelier/languages')).toHaveLength(0)
})
it('sends the new value with the full state', async () => {
await typeInto(field(), 'fr_CA')
await press('Save social locale', dialogBox()!)
const body = callsTo('PUT', '/v1/site/atelier/languages')[0]!.body as {
languages: { lang: string; social_locale: string | null }[]
}
expect(body.languages.find((row) => row.lang === 'fr')!.social_locale).toBe('fr_CA')
expect(body.languages).toHaveLength(4)
})
it('sends null for an empty field', async () => {
await typeInto(field(), '')
await press('Save social locale', dialogBox()!)
const body = callsTo('PUT', '/v1/site/atelier/languages')[0]!.body as {
languages: { lang: string; social_locale: string | null }[]
}
expect(body.languages.find((row) => row.lang === 'fr')!.social_locale).toBeNull()
})
})
describe('Social locale refused by the server', () => {
it('shows the same field error as the invalid state', async () => {
await mountRoute('/settings/sites/atelier/languages', {
routes: {
'GET /v1/site/': SITES,
'GET /v1/site/atelier/languages': ATELIER,
'PUT /v1/site/atelier/languages': failWith(422, 'invalid_social_locale'),
},
})
await press('Edit social locale for French')
await typeInto(dialogBox()!.querySelector('input')!, 'fr_XX')
await press('Save social locale', dialogBox()!)
expect(dialogText()).toContain('Use letters and one underscore, for example fr_FR.')
})
})
@@ -0,0 +1,211 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { initializeLocale } from '@/i18n'
import { ARCHIVE, ATELIER, JOURNAL, SITES, languagesBody } from './localizationData'
import {
callsTo,
control,
deferred,
failWith,
kit,
mountRoute,
press,
settle,
text,
unmountRoute,
} from './localizationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => initializeLocale())
afterEach(unmountRoute)
function routes(key: string, languages: unknown) {
return { 'GET /v1/site/': SITES, [`GET /v1/site/${key}/languages`]: languages }
}
describe('loc.l1.loading: Loading', () => {
it('shows the heading, the subtitle and 4 skeleton rows while the GET is in flight', async () => {
const pending = deferred<unknown>()
await mountRoute('/settings/sites/journal/languages', {
routes: routes('journal', () => pending.promise),
})
expect(text()).toContain('Languages')
expect(text()).toContain(
'Content languages for Journal. Each article version is written in one language.',
)
expect(document.querySelectorAll('[role="status"] > div')).toHaveLength(4)
pending.resolve(JOURNAL)
})
})
describe('loc.l1.error: Load failed', () => {
it('shows the alert and repeats the GET on Try again', async () => {
await mountRoute('/settings/sites/journal/languages', {
routes: routes('journal', failWith(500, 'Internal server error')),
})
expect(text()).toContain("Couldn't load languages")
expect(text()).toContain('Check your connection and try again.')
await press('Try again')
expect(callsTo('GET', '/v1/site/journal/languages')).toHaveLength(2)
})
it('shows the same state for an unknown site key', async () => {
await mountRoute('/settings/sites/nowhere/languages', {
routes: routes('nowhere', failWith(404, 'site_not_found')),
})
expect(text()).toContain("Couldn't load languages")
})
})
describe('loc.l1.single: Single language', () => {
it('shows the card for the one language of Archive', async () => {
await mountRoute('/settings/sites/archive/languages', {
routes: routes('archive', ARCHIVE),
})
const page = text()
expect(page).toContain('Archive uses one language')
expect(page).toContain(
'New articles are written in Portuguese (Brazil). Add a language to write translations and publish them separately.',
)
expect(page).toContain('Default')
expect(page).toContain('pt-BR')
expect(page).toContain('Social locale')
expect(page).toContain('Derived: pt_BR')
expect(page).toContain('Regional language kept: pt-BR')
expect(control('Add language')).toBeTruthy()
})
})
describe('loc.l1.multi: Multilingual', () => {
beforeEach(async () => {
await mountRoute('/settings/sites/journal/languages', {
routes: routes('journal', JOURNAL),
})
})
it('lists one row per language with its counts and social locale', () => {
const page = text()
expect(page).toContain('Language')
for (const name of ['English', 'French', 'German', 'Arabic', 'Spanish', 'Italian']) {
expect(page).toContain(name)
}
expect(page).toContain('7 versions · 6 published')
expect(page).toContain('4 versions · 2 published')
expect(page).toContain('1 version · 0 published')
expect(page).toContain('Set: fr_FR')
expect(page).toContain('Not set')
})
it('tags the default and the disabled languages', () => {
expect(document.querySelector('[data-testid="language-row-en"]')?.textContent).toContain(
'Default',
)
expect(document.querySelector('[data-testid="language-row-es"]')?.textContent).toContain(
'Disabled',
)
})
it('gives every row its Edit and actions buttons, and a Re-enable button on disabled rows', () => {
expect(control('Edit social locale for French')).toBeTruthy()
expect(control('Actions for French')).toBeTruthy()
expect(
document.querySelectorAll('[data-testid="language-row-es"] button').length,
).toBeGreaterThan(2)
expect(
control('Re-enable', document.querySelector('[data-testid="language-row-es"]')!),
).toBeTruthy()
expect(
control('Re-enable', document.querySelector('[data-testid="language-row-fr"]')!),
).toBeUndefined()
})
it('explains disabling and the three social locale states', () => {
expect(text()).toContain(
'Disabling a language keeps its versions readable and editable, and published ones stay online. Drafts can be published again after you re-enable it. Social locale: Derived comes from the language code, Set is chosen by an owner, Not set leaves og:locale out.',
)
})
})
describe('loc.l1.readonly: Read-only view', () => {
it('shows the rows and no control for a user without settings.manage_sites', async () => {
await mountRoute('/settings/sites/journal/languages', {
role: 'viewer',
routes: routes('journal', JOURNAL),
})
const page = text()
expect(page).toContain('Only owners can change languages')
expect(page).toContain(
'You can see which languages are enabled, their social locale and how many versions each has. Ask an owner to change them.',
)
expect(page).toContain('7 versions · 6 published')
expect(control('Add language')).toBeUndefined()
expect(control(/^Edit social locale/)).toBeUndefined()
expect(control('Re-enable')).toBeUndefined()
expect(control(/^Actions for/)).toBeUndefined()
})
})
describe('loc.l1.social: Social locale', () => {
it('shows Derived, Set and Not set in the rows of Atelier', async () => {
await mountRoute('/settings/sites/atelier/languages', {
routes: routes('atelier', ATELIER),
})
const page = text()
expect(page).toContain('Derived: en_GB')
expect(page).toContain('Derived: pt_BR')
expect(page).toContain('Set: fr_FR')
expect(page).toContain('Not set')
})
})
describe('Setup banner', () => {
const pending = { ...languagesBody('archive-0', 'pt-BR', []), default: null, needs_setup: true }
it('links an owner to the setup page of the site', async () => {
await mountRoute('/settings/sites/archive/languages', {
routes: routes('archive', pending),
})
const link = control('Set up languages', document.body)
const banner = Array.from(document.querySelectorAll('a')).find(
(anchor) => anchor.textContent?.trim() === 'Set up languages',
)
expect(link).toBeTruthy()
expect(banner?.getAttribute('href')).toBe('/settings/sites/archive/languages/setup')
expect(control('Add language')).toBeUndefined()
})
it('shows no action to a reader', async () => {
await mountRoute('/settings/sites/archive/languages', {
role: 'viewer',
routes: routes('archive', pending),
})
expect(text()).toContain('Set up languages')
expect(document.querySelector('a[href$="/languages/setup"]')).toBeNull()
})
})
describe('Requests', () => {
it('asks for nothing else than the languages and the sites', async () => {
await mountRoute('/settings/sites/journal/languages', {
routes: routes('journal', JOURNAL),
})
await settle()
const made = kit.calls.map((call) => `${call.method} ${call.path}`)
expect(made.filter((call) => !call.endsWith('/v1/content-type')).sort()).toEqual([
'GET /v1/site/',
'GET /v1/site/journal/languages',
])
})
})
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { chooseMenuItem, mountDirectory, openRowMenu, unmountDirectory } from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountDirectory)
function menuLabels() {
return Array.from(document.body.querySelectorAll('[role="menuitem"]')).map((item) =>
item.textContent?.trim(),
)
}
describe('Settings, Sites row actions for localization', () => {
it('offers Languages and Public URL on each site row', async () => {
await mountDirectory('sites', '/settings/sites')
await openRowMenu('Journal')
expect(menuLabels()).toEqual(
expect.arrayContaining(['Edit', 'Languages', 'Public URL', 'Delete…']),
)
})
it.each([
['Languages', '/settings/sites/journal/languages'],
['Public URL', '/settings/sites/journal/public-url'],
])('opens %s at %s', async (label, target) => {
await mountDirectory('sites', '/settings/sites')
await openRowMenu('Journal')
await chooseMenuItem(label)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(target))
})
})
+2 -2
View File
@@ -131,7 +131,7 @@ describe('Sites: row menu', () => {
await openRowMenu('Journal')
const labels = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)
expect(labels).toEqual(['Edit', 'Set as default', 'Delete…'])
expect(labels).toEqual(['Edit', 'Languages', 'Public URL', 'Set as default', 'Delete…'])
})
it('opens a bottom sheet titled with the site name below md', async () => {
@@ -142,7 +142,7 @@ describe('Sites: row menu', () => {
const sheet = dialog()!
expect(textOf(sheet.querySelector('h2'))).toBe('Journal')
expect(sheet.querySelectorAll('[role="menuitem"]')).toHaveLength(3)
expect(sheet.querySelectorAll('[role="menuitem"]')).toHaveLength(5)
})
})
@@ -0,0 +1,84 @@
export interface LanguageFixture {
lang: string
enabled?: boolean
versions: number
published: number
social?: string
derived?: boolean
}
export const AVAILABLE = [
...['en', 'en-GB', 'en-US', 'fr', 'fr-CA', 'pt', 'pt-BR', 'es', 'de', 'ar'].map((lang) => ({
lang,
substitute: null,
})),
{ lang: 'pt-PT', substitute: 'pt' },
]
export function languagesBody(revision: string, def: string, rows: LanguageFixture[]) {
return {
revision,
default: def,
needs_setup: false,
languages: rows.map((row) => ({
lang: row.lang,
enabled: row.enabled ?? true,
is_default: row.lang === def,
version_count: row.versions,
published_count: row.published,
social_locale: row.social ?? null,
social_locale_state: row.social ? 'set' : row.derived ? 'derived' : 'not_set',
})),
available: AVAILABLE,
}
}
export const JOURNAL_ROWS: LanguageFixture[] = [
{ lang: 'en', versions: 7, published: 6 },
{ lang: 'fr', versions: 4, published: 2, social: 'fr_FR' },
{ lang: 'de', versions: 4, published: 3 },
{ lang: 'ar', versions: 1, published: 0 },
{ lang: 'es', enabled: false, versions: 1, published: 1 },
{ lang: 'it', enabled: false, versions: 0, published: 0 },
]
export const JOURNAL = languagesBody('journal-1', 'en', JOURNAL_ROWS)
export const ATELIER = languagesBody('atelier-1', 'en', [
{ lang: 'en', versions: 3, published: 2 },
{ lang: 'en-GB', versions: 3, published: 1, derived: true },
{ lang: 'fr', versions: 2, published: 0, social: 'fr_FR' },
{ lang: 'pt-BR', versions: 1, published: 0, derived: true },
])
export const ARCHIVE = languagesBody('archive-1', 'pt-BR', [
{ lang: 'pt-BR', versions: 5, published: 4, derived: true },
])
export function siteRow(
id: number,
key: string,
name: string,
extra: Record<string, unknown> = {},
) {
return {
id,
name,
key,
base_url: `https://${key}.example.com`,
default_social_image_id: null,
default_robots: 'index',
locale: null,
is_default: id === 1,
article_count: 0,
public_url_pattern: null,
...extra,
}
}
export const SITES = [
siteRow(1, 'journal', 'Journal', { locale: 'en', article_count: 8 }),
siteRow(2, 'atelier', 'Atelier'),
siteRow(3, 'archive', 'Archive'),
siteRow(4, 'notes', 'Notes'),
]
+157
View File
@@ -0,0 +1,157 @@
import { vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { RouterView } from 'vue-router'
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 { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { accessFor } from '@/test/access'
export interface Call {
method: string
path: string
query: URLSearchParams
body: Record<string, unknown> | undefined
}
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 {
return () => {
throw new ApiError(status, undefined, body)
}
}
export function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
export const WRITE_FAILS = failWith(500, 'Internal server error')
export function callsTo(method: string, path: string) {
return kit.calls.filter((call) => call.method === method && call.path === path)
}
interface MountOptions {
role?: string
routes: Record<string, Route>
width?: number
}
export async function mountRoute(url: string, options: MountOptions) {
setViewportWidth(options.width ?? DESKTOP_WIDTH)
Element.prototype.scrollIntoView = vi.fn()
setActivePinia(createPinia())
kit.calls = []
const session = useSessionStore()
session.session = {
verified: true,
username: 'marta',
id: 1,
email: 'marta@renew.example',
displayName: 'Marta Alves',
access: accessFor(options.role ?? 'owner'),
}
session.status = 'ready'
const request = vi.fn(async (path: string, init?: RequestInit) => {
const [pathname = '', search = ''] = path.split('?')
const call: Call = {
method: init?.method ?? 'GET',
path: pathname,
query: new URLSearchParams(search),
body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined,
}
kit.calls.push(call)
const key = `${call.method} ${pathname}`
if (!(key in options.routes)) {
throw new Error(`Unexpected request: ${key}`)
}
const route = options.routes[key]
return typeof route === 'function' ? route(call) : structuredClone(route)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
request,
requestPaged: vi.fn(),
} as unknown as ReturnType<typeof useApi>)
await router.push(url)
kit.wrapper = mount(
defineComponent({ render: () => h(UApp, null, { default: () => h(RouterView) }) }),
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
)
await settle()
}
export async function settle() {
for (let round = 0; round < 6; round++) {
await flushPromises()
}
}
export async function unmountRoute() {
kit.wrapper?.unmount()
kit.wrapper = null
await router.push('/login')
document.body.innerHTML = ''
}
export function text() {
return (document.body.textContent ?? '').replace(/\s+/g, ' ').trim()
}
export function dialogBox() {
return document.body.querySelector<HTMLElement>('[role="dialog"], [role="alertdialog"]')
}
export function dialogText() {
return (dialogBox()?.textContent ?? '').replace(/\s+/g, ' ').trim()
}
export function control(label: string | RegExp, root: ParentNode = document.body) {
return Array.from(
root.querySelectorAll<HTMLElement>('button, [role="menuitem"], a, [role="checkbox"]'),
).find((element) => {
const name = (element.getAttribute('aria-label') || element.textContent || '')
.replace(/\s+/g, ' ')
.trim()
return typeof label === 'string' ? name === label : label.test(name)
})
}
export async function press(label: string | RegExp, root: ParentNode = document.body) {
const target = control(label, root)
if (!target) {
throw new Error(`No control named ${String(label)}`)
}
target.click()
await settle()
}
export async function chooseAction(language: string, item: string) {
await press(`Actions for ${language}`)
await press(item)
}
export async function typeInto(element: HTMLInputElement, value: string) {
element.value = value
element.dispatchEvent(new Event('input', { bubbles: true }))
await settle()
}
export function toastText() {
return (
document.body.querySelector(
'[data-slot="viewport"], [role="region"][aria-label*="otification"]',
)?.textContent ?? ''
)
}