feat: site languages, setup and site URL settings
This commit is contained in:
1 parent
0806733d5c
commit
d9158025d4
106 files changed
+10184
-56
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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 ?? ''
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user